@cosense-toolbox/lsp 0.1.0-beta.2
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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/check.d.mts +57 -0
- package/dist/check.d.mts.map +1 -0
- package/dist/check.mjs +89 -0
- package/dist/check.mjs.map +1 -0
- package/dist/completion.d.mts +52 -0
- package/dist/completion.d.mts.map +1 -0
- package/dist/completion.mjs +139 -0
- package/dist/completion.mjs.map +1 -0
- package/dist/main.d.mts +1 -0
- package/dist/main.mjs +21 -0
- package/dist/main.mjs.map +1 -0
- package/dist/server-DPPxtJ6Y.mjs +100 -0
- package/dist/server-DPPxtJ6Y.mjs.map +1 -0
- package/dist/tokens-D0NP_uLo.mjs +312 -0
- package/dist/tokens-D0NP_uLo.mjs.map +1 -0
- package/dist/tokens.d.mts +80 -0
- package/dist/tokens.d.mts.map +1 -0
- package/dist/tokens.mjs +2 -0
- package/dist/workspace-DouAOZnK.mjs +171 -0
- package/dist/workspace-DouAOZnK.mjs.map +1 -0
- package/dist/workspace-pdw-fg9B.d.mts +29 -0
- package/dist/workspace-pdw-fg9B.d.mts.map +1 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 qaynam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @cosense-toolbox/lsp
|
|
2
|
+
|
|
3
|
+
> **beta.** 設定の名前や出力の形はまだ変わりえます。
|
|
4
|
+
|
|
5
|
+
`.csn` / `.csnx` の Language Server と、同じ判定でサイト全体を調べる `check` コマンド。
|
|
6
|
+
どちらも [`@cosense-toolbox/parser`](../parser) で読むので、記法の解釈が Cosense の描画とずれない。
|
|
7
|
+
|
|
8
|
+
## Language Server
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
csn-lsp --stdio
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- 色付け (semantic tokens)
|
|
15
|
+
- `[` の中と `#` の後での、ページの題名の補完
|
|
16
|
+
- `[ページ名]` からそのページのファイルへの定義ジャンプ
|
|
17
|
+
- 存在しないページへのリンクの診断
|
|
18
|
+
|
|
19
|
+
設定はエディタの `initialization_options` で渡す。どれも省略できる。
|
|
20
|
+
|
|
21
|
+
| 設定 | 内容 | 既定 |
|
|
22
|
+
| :---------------- | :------------------------------------------------------------------------------------- | :----------------- |
|
|
23
|
+
| `sources` | ページを読む場所。ワークスペースからの相対パス | ワークスペース全体 |
|
|
24
|
+
| `decorations` | サイトの `customDecorations` と同じ記号。`[! 注意]` をリンクではなく装飾記法として読む | なし |
|
|
25
|
+
| `unresolvedLinks` | 存在しないページへのリンクの診断。`off` / `hint` / `information` / `warning` / `error` | `warning` |
|
|
26
|
+
| `frontmatter` | 1 行目の `---` を frontmatter (YAML) として飛ばすか | `true` |
|
|
27
|
+
|
|
28
|
+
## check
|
|
29
|
+
|
|
30
|
+
エディタの診断と同じ判定で、サイトのすべてのページのリンクを調べる。CI で使う想定。
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
csn-lsp check src/content src/pages --decorations '|!~#'
|
|
34
|
+
# src/content/posts/a.csn:5:3 error リンク先のページが見つからない: [無いページ]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- 引数のディレクトリの下の `.csn` / `.csnx` を読む。省略すると今いるディレクトリを読む
|
|
38
|
+
- ページの題名はファイルの 1 行目。大文字小文字と、空白と `_` の違いは無視する
|
|
39
|
+
- エラーが 1 件でもあれば終了コード 1、無ければ 0。オプションの誤りは 2
|
|
40
|
+
|
|
41
|
+
| オプション | 内容 | 既定 |
|
|
42
|
+
| :--------------------------- | :--------------------------------------------------- | :------ |
|
|
43
|
+
| `--unresolved-links <level>` | `off` / `hint` / `information` / `warning` / `error` | `error` |
|
|
44
|
+
| `--decorations <markers>` | サイトの装飾記法の記号を 1 つの文字列で (`'\|!~#'`) | なし |
|
|
45
|
+
| `--no-frontmatter` | 1 行目の `---` を frontmatter ではなく題名として読む | |
|
|
46
|
+
|
|
47
|
+
`check` の既定が `error` なのは、ビルドを止めるための道具だから。報告だけにしたいときは
|
|
48
|
+
`--unresolved-links warning` にする (終了コードは 0 になる)。
|
|
49
|
+
|
|
50
|
+
## ライブラリとして
|
|
51
|
+
|
|
52
|
+
エディタのプラグインなどから、部品として使える。
|
|
53
|
+
|
|
54
|
+
- `@cosense-toolbox/lsp/tokens`: `computeTokens` / `encodeTokens` / `legendOf` / `withNotations` など
|
|
55
|
+
- `notations` で独自の記法 (記号と、送るトークンの型の名前) を足せる
|
|
56
|
+
- `frontmatter: false` で、1 行目の `---` を YAML として飛ばさずに読める
|
|
57
|
+
- `@cosense-toolbox/lsp/completion`: `detectCompletion` / `completionItems` / `definitionOf` など
|
package/dist/check.d.mts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import "./workspace-pdw-fg9B.mjs";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import { ParseOptions } from "@cosense-toolbox/parser";
|
|
4
|
+
import "vscode-languageserver/node";
|
|
5
|
+
//#region src/diagnostics.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Links to pages no file holds.
|
|
8
|
+
*
|
|
9
|
+
* Only `[title]` links are checked. A tag names a tag page more often than a page, and a
|
|
10
|
+
* `[/project/page]` link points outside the workspace; neither is expected to have a file.
|
|
11
|
+
*/
|
|
12
|
+
/** How loudly an unresolved link is reported, as the reader sets it. */
|
|
13
|
+
declare const UnresolvedSeverity: Schema.Literal<["off", "hint", "information", "warning", "error"]>;
|
|
14
|
+
type UnresolvedSeverity = typeof UnresolvedSeverity.Type;
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/check.d.ts
|
|
17
|
+
/**
|
|
18
|
+
* `csn-lsp check`: the server's diagnostics for every page of a site, for a terminal or CI.
|
|
19
|
+
*
|
|
20
|
+
* The same functions decide as in the editor, so what the editor flags is what fails here.
|
|
21
|
+
*/
|
|
22
|
+
declare const USAGE = "Usage: csn-lsp check [directory ...] [options]\n\nReads the .csn and .csnx pages under each directory (default: the current one) and reports\nevery [link] to a page none of them holds.\n\nOptions:\n --unresolved-links <level> off | hint | information | warning | error (default: error)\n --decorations <markers> the site's own decoration markers, as one string: '|!~#'\n --no-frontmatter read a first line of --- as the title, not as YAML\n";
|
|
23
|
+
/** What the command prints, and the code it exits with. */
|
|
24
|
+
interface CheckResult {
|
|
25
|
+
readonly output: string;
|
|
26
|
+
readonly exitCode: number;
|
|
27
|
+
}
|
|
28
|
+
/** Where to look and how to read, for `checkSite`. */
|
|
29
|
+
interface CheckSiteOptions {
|
|
30
|
+
/** The directories whose `.csn` / `.csnx` pages are read, and checked against each other. */
|
|
31
|
+
readonly roots: ReadonlyArray<string>;
|
|
32
|
+
/** How a link to a missing page is reported; `off` reports none. */
|
|
33
|
+
readonly unresolvedLinks: UnresolvedSeverity;
|
|
34
|
+
/** How to parse: a site's notation extensions, as its build parses with. */
|
|
35
|
+
readonly parseOptions?: ParseOptions;
|
|
36
|
+
/** Whether a `---` fence on a page's first line opens YAML to skip (default: true). */
|
|
37
|
+
readonly frontmatter?: boolean;
|
|
38
|
+
}
|
|
39
|
+
type ReportLevel = "error" | "warning" | "information" | "hint";
|
|
40
|
+
/** One thing found: where, counting lines and columns from 1, how loudly, and what. */
|
|
41
|
+
interface Report {
|
|
42
|
+
readonly path: string;
|
|
43
|
+
readonly line: number;
|
|
44
|
+
readonly column: number;
|
|
45
|
+
readonly level: ReportLevel;
|
|
46
|
+
readonly message: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Every link to a missing page under `roots`, file by file in the order they were read.
|
|
50
|
+
* The judgement is the editor's own, so what an editor flags is what is reported here.
|
|
51
|
+
*/
|
|
52
|
+
declare const checkSite: ({ roots, unresolvedLinks, parseOptions, frontmatter }: CheckSiteOptions) => Effect.Effect<ReadonlyArray<Report>>;
|
|
53
|
+
/** Runs a check as `args` asks, from `cwd`. Never fails: a bad option is exit code 2. */
|
|
54
|
+
declare const runCheck: (args: ReadonlyArray<string>, cwd: string) => Effect.Effect<CheckResult>;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { CheckResult, CheckSiteOptions, Report, ReportLevel, USAGE, checkSite, runCheck };
|
|
57
|
+
//# sourceMappingURL=check.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"check.d.mts","names":[],"sources":["../src/diagnostics.ts","../src/check.ts"],"mappings":";;;;;;;;;;;;cAsBa,oBAAkB,OAAA;KAEnB,4BAA4B,mBAAmB;;;;;;;;cCP9C;;UAYI;WACN;WACA;;;UAIM;;WAEN,OAAO;;WAEP,iBAAiB;;WAEjB,eAAe;;WAEf;;KAGC;;UAGK;WACN;WACA;WACA;WACA,OAAO;WACP;;;;;;cA+DE,cAAa,OAAA,iBAAA,cAAA,eAKvB,qBAAmB,OAAO,OAAO,cAAc;;cAuBrC,WAAY,MAAM,uBAAuB,gBAAc,OAAO,OAAO"}
|
package/dist/check.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { i as readPageFiles, l as severityOf, n as indexOf, o as defaultSettings, s as parseOptionsOf, u as unresolvedLinkDiagnostics } from "./workspace-DouAOZnK.mjs";
|
|
2
|
+
import { Array as Array$1, Effect, Match, Option, Predicate, pipe } from "effect";
|
|
3
|
+
import { DiagnosticSeverity } from "vscode-languageserver/node";
|
|
4
|
+
import { relative, resolve, sep } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
//#region src/check.ts
|
|
7
|
+
/**
|
|
8
|
+
* `csn-lsp check`: the server's diagnostics for every page of a site, for a terminal or CI.
|
|
9
|
+
*
|
|
10
|
+
* The same functions decide as in the editor, so what the editor flags is what fails here.
|
|
11
|
+
*/
|
|
12
|
+
const USAGE = `Usage: csn-lsp check [directory ...] [options]
|
|
13
|
+
|
|
14
|
+
Reads the .csn and .csnx pages under each directory (default: the current one) and reports
|
|
15
|
+
every [link] to a page none of them holds.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--unresolved-links <level> off | hint | information | warning | error (default: error)
|
|
19
|
+
--decorations <markers> the site's own decoration markers, as one string: '|!~#'
|
|
20
|
+
--no-frontmatter read a first line of --- as the title, not as YAML
|
|
21
|
+
`;
|
|
22
|
+
/**
|
|
23
|
+
* Reports are errors unless told otherwise: the command exists to stop a build, and a
|
|
24
|
+
* warning that passes would not.
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_LEVEL = "error";
|
|
27
|
+
const argsOf = (args, cwd) => pipe(Option.liftThrowable(parseArgs)({
|
|
28
|
+
args: [...args],
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
strict: true,
|
|
31
|
+
options: {
|
|
32
|
+
"unresolved-links": { type: "string" },
|
|
33
|
+
decorations: { type: "string" },
|
|
34
|
+
"no-frontmatter": { type: "boolean" }
|
|
35
|
+
}
|
|
36
|
+
}), Option.map(({ values, positionals }) => ({
|
|
37
|
+
roots: Array$1.match(positionals, {
|
|
38
|
+
onEmpty: () => [cwd],
|
|
39
|
+
onNonEmpty: (directories) => Array$1.map(directories, (directory) => resolve(cwd, directory))
|
|
40
|
+
}),
|
|
41
|
+
unresolvedLinks: pipe(Option.fromNullable(values["unresolved-links"]), Option.match({
|
|
42
|
+
onNone: () => DEFAULT_LEVEL,
|
|
43
|
+
onSome: severityOf
|
|
44
|
+
})),
|
|
45
|
+
parseOptions: parseOptionsOf({
|
|
46
|
+
...defaultSettings,
|
|
47
|
+
decorations: Array.from(values.decorations ?? "")
|
|
48
|
+
}),
|
|
49
|
+
frontmatter: values["no-frontmatter"] !== true
|
|
50
|
+
})));
|
|
51
|
+
const levelOf = (severity) => Match.value(severity).pipe(Match.when(DiagnosticSeverity.Error, () => "error"), Match.when(DiagnosticSeverity.Warning, () => "warning"), Match.when(DiagnosticSeverity.Information, () => "information"), Match.orElse(() => "hint"));
|
|
52
|
+
/** A file's diagnostic as a report, counting from 1 where the LSP counts from 0. */
|
|
53
|
+
const reportOf = (file) => ({ range, severity, message }) => ({
|
|
54
|
+
path: file.path,
|
|
55
|
+
line: range.start.line + 1,
|
|
56
|
+
column: range.start.character + 1,
|
|
57
|
+
level: levelOf(severity),
|
|
58
|
+
message: Predicate.isString(message) ? message : message.value
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* Every link to a missing page under `roots`, file by file in the order they were read.
|
|
62
|
+
* The judgement is the editor's own, so what an editor flags is what is reported here.
|
|
63
|
+
*/
|
|
64
|
+
const checkSite = ({ roots, unresolvedLinks, parseOptions = {}, frontmatter = true }) => Effect.map(readPageFiles(roots, { frontmatter }), (files) => {
|
|
65
|
+
const index = indexOf(files);
|
|
66
|
+
return Array$1.flatMap(files, (file) => Array$1.map(unresolvedLinkDiagnostics(index, file.text, {
|
|
67
|
+
severity: unresolvedLinks,
|
|
68
|
+
components: file.path.endsWith(".csnx"),
|
|
69
|
+
parseOptions,
|
|
70
|
+
frontmatter
|
|
71
|
+
}), reportOf(file)));
|
|
72
|
+
});
|
|
73
|
+
/** A report as a line of output: `path:line:column level message`, `path` from `cwd`. */
|
|
74
|
+
const lineOf = (cwd) => ({ path, line, column, level, message }) => `${relative(cwd, path).split(sep).join("/")}:${line}:${column} ${level} ${message}\n`;
|
|
75
|
+
/** Runs a check as `args` asks, from `cwd`. Never fails: a bad option is exit code 2. */
|
|
76
|
+
const runCheck = (args, cwd) => Option.match(argsOf(args, cwd), {
|
|
77
|
+
onNone: () => Effect.succeed({
|
|
78
|
+
output: USAGE,
|
|
79
|
+
exitCode: 2
|
|
80
|
+
}),
|
|
81
|
+
onSome: (options) => Effect.map(checkSite(options), (reports) => ({
|
|
82
|
+
output: Array$1.map(reports, lineOf(cwd)).join(""),
|
|
83
|
+
exitCode: Array$1.some(reports, ({ level }) => level === "error") ? 1 : 0
|
|
84
|
+
}))
|
|
85
|
+
});
|
|
86
|
+
//#endregion
|
|
87
|
+
export { USAGE, checkSite, runCheck };
|
|
88
|
+
|
|
89
|
+
//# sourceMappingURL=check.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"check.mjs","names":["Arr"],"sources":["../src/check.ts"],"sourcesContent":["import { relative, resolve, sep } from \"node:path\"\nimport { parseArgs } from \"node:util\"\n\nimport type { ParseOptions } from \"@cosense-toolbox/parser\"\nimport { Array as Arr, Effect, Match, Option, pipe, Predicate } from \"effect\"\nimport { type Diagnostic, DiagnosticSeverity } from \"vscode-languageserver/node\"\n\nimport { severityOf, unresolvedLinkDiagnostics, type UnresolvedSeverity } from \"./diagnostics\"\nimport { defaultSettings, parseOptionsOf } from \"./settings\"\nimport { indexOf, type PageFile, readPageFiles } from \"./workspace\"\n\n/**\n * `csn-lsp check`: the server's diagnostics for every page of a site, for a terminal or CI.\n *\n * The same functions decide as in the editor, so what the editor flags is what fails here.\n */\n\nexport const USAGE = `Usage: csn-lsp check [directory ...] [options]\n\nReads the .csn and .csnx pages under each directory (default: the current one) and reports\nevery [link] to a page none of them holds.\n\nOptions:\n --unresolved-links <level> off | hint | information | warning | error (default: error)\n --decorations <markers> the site's own decoration markers, as one string: '|!~#'\n --no-frontmatter read a first line of --- as the title, not as YAML\n`\n\n/** What the command prints, and the code it exits with. */\nexport interface CheckResult {\n readonly output: string\n readonly exitCode: number\n}\n\n/** Where to look and how to read, for `checkSite`. */\nexport interface CheckSiteOptions {\n /** The directories whose `.csn` / `.csnx` pages are read, and checked against each other. */\n readonly roots: ReadonlyArray<string>\n /** How a link to a missing page is reported; `off` reports none. */\n readonly unresolvedLinks: UnresolvedSeverity\n /** How to parse: a site's notation extensions, as its build parses with. */\n readonly parseOptions?: ParseOptions\n /** Whether a `---` fence on a page's first line opens YAML to skip (default: true). */\n readonly frontmatter?: boolean\n}\n\nexport type ReportLevel = \"error\" | \"warning\" | \"information\" | \"hint\"\n\n/** One thing found: where, counting lines and columns from 1, how loudly, and what. */\nexport interface Report {\n readonly path: string\n readonly line: number\n readonly column: number\n readonly level: ReportLevel\n readonly message: string\n}\n\n/**\n * Reports are errors unless told otherwise: the command exists to stop a build, and a\n * warning that passes would not.\n */\nconst DEFAULT_LEVEL: UnresolvedSeverity = \"error\"\n\nconst argsOf = (args: ReadonlyArray<string>, cwd: string): Option.Option<CheckSiteOptions> =>\n pipe(\n Option.liftThrowable(parseArgs)({\n args: [...args],\n allowPositionals: true,\n strict: true,\n options: {\n \"unresolved-links\": { type: \"string\" },\n decorations: { type: \"string\" },\n \"no-frontmatter\": { type: \"boolean\" },\n },\n }),\n Option.map(({ values, positionals }) => ({\n roots: Arr.match(positionals, {\n onEmpty: () => [cwd],\n onNonEmpty: (directories) => Arr.map(directories, (directory) => resolve(cwd, directory)),\n }),\n unresolvedLinks: pipe(\n Option.fromNullable(values[\"unresolved-links\"]),\n Option.match({ onNone: () => DEFAULT_LEVEL, onSome: severityOf }),\n ),\n // Each marker is one character, so a string of them is the list.\n parseOptions: parseOptionsOf({\n ...defaultSettings,\n decorations: Array.from(values.decorations ?? \"\"),\n }),\n frontmatter: values[\"no-frontmatter\"] !== true,\n })),\n )\n\nconst levelOf = (severity: DiagnosticSeverity | undefined): ReportLevel =>\n Match.value(severity).pipe(\n Match.when(DiagnosticSeverity.Error, (): ReportLevel => \"error\"),\n Match.when(DiagnosticSeverity.Warning, (): ReportLevel => \"warning\"),\n Match.when(DiagnosticSeverity.Information, (): ReportLevel => \"information\"),\n Match.orElse((): ReportLevel => \"hint\"),\n )\n\n/** A file's diagnostic as a report, counting from 1 where the LSP counts from 0. */\nconst reportOf =\n (file: PageFile) =>\n ({ range, severity, message }: Diagnostic): Report => ({\n path: file.path,\n line: range.start.line + 1,\n column: range.start.character + 1,\n level: levelOf(severity),\n // The LSP lets a message be markup; a report is text either way.\n message: Predicate.isString(message) ? message : message.value,\n })\n\n/**\n * Every link to a missing page under `roots`, file by file in the order they were read.\n * The judgement is the editor's own, so what an editor flags is what is reported here.\n */\nexport const checkSite = ({\n roots,\n unresolvedLinks,\n parseOptions = {},\n frontmatter = true,\n}: CheckSiteOptions): Effect.Effect<ReadonlyArray<Report>> =>\n Effect.map(readPageFiles(roots, { frontmatter }), (files) => {\n const index = indexOf(files)\n return Arr.flatMap(files, (file) =>\n Arr.map(\n unresolvedLinkDiagnostics(index, file.text, {\n severity: unresolvedLinks,\n components: file.path.endsWith(\".csnx\"),\n parseOptions,\n frontmatter,\n }),\n reportOf(file),\n ),\n )\n })\n\n/** A report as a line of output: `path:line:column level message`, `path` from `cwd`. */\nconst lineOf =\n (cwd: string) =>\n ({ path, line, column, level, message }: Report): string =>\n `${relative(cwd, path).split(sep).join(\"/\")}:${line}:${column} ${level} ${message}\\n`\n\n/** Runs a check as `args` asks, from `cwd`. Never fails: a bad option is exit code 2. */\nexport const runCheck = (args: ReadonlyArray<string>, cwd: string): Effect.Effect<CheckResult> =>\n Option.match(argsOf(args, cwd), {\n onNone: () => Effect.succeed({ output: USAGE, exitCode: 2 }),\n onSome: (options) =>\n Effect.map(checkSite(options), (reports) => ({\n output: Arr.map(reports, lineOf(cwd)).join(\"\"),\n exitCode: Arr.some(reports, ({ level }) => level === \"error\") ? 1 : 0,\n })),\n })\n"],"mappings":";;;;;;;;;;;AAiBA,MAAa,QAAQ;;;;;;;;;;;;;;AA4CrB,MAAM,gBAAoC;AAE1C,MAAM,UAAU,MAA6B,QAC3C,KACE,OAAO,cAAc,SAAS,CAAC,CAAC;CAC9B,MAAM,CAAC,GAAG,IAAI;CACd,kBAAkB;CAClB,QAAQ;CACR,SAAS;EACP,oBAAoB,EAAE,MAAM,SAAS;EACrC,aAAa,EAAE,MAAM,SAAS;EAC9B,kBAAkB,EAAE,MAAM,UAAU;CACtC;AACF,CAAC,GACD,OAAO,KAAK,EAAE,QAAQ,mBAAmB;CACvC,OAAOA,QAAI,MAAM,aAAa;EAC5B,eAAe,CAAC,GAAG;EACnB,aAAa,gBAAgBA,QAAI,IAAI,cAAc,cAAc,QAAQ,KAAK,SAAS,CAAC;CAC1F,CAAC;CACD,iBAAiB,KACf,OAAO,aAAa,OAAO,mBAAmB,GAC9C,OAAO,MAAM;EAAE,cAAc;EAAe,QAAQ;CAAW,CAAC,CAClE;CAEA,cAAc,eAAe;EAC3B,GAAG;EACH,aAAa,MAAM,KAAK,OAAO,eAAe,EAAE;CAClD,CAAC;CACD,aAAa,OAAO,sBAAsB;AAC5C,EAAE,CACJ;AAEF,MAAM,WAAW,aACf,MAAM,MAAM,QAAQ,CAAC,CAAC,KACpB,MAAM,KAAK,mBAAmB,aAA0B,OAAO,GAC/D,MAAM,KAAK,mBAAmB,eAA4B,SAAS,GACnE,MAAM,KAAK,mBAAmB,mBAAgC,aAAa,GAC3E,MAAM,aAA0B,MAAM,CACxC;;AAGF,MAAM,YACH,UACA,EAAE,OAAO,UAAU,eAAmC;CACrD,MAAM,KAAK;CACX,MAAM,MAAM,MAAM,OAAO;CACzB,QAAQ,MAAM,MAAM,YAAY;CAChC,OAAO,QAAQ,QAAQ;CAEvB,SAAS,UAAU,SAAS,OAAO,IAAI,UAAU,QAAQ;AAC3D;;;;;AAMF,MAAa,aAAa,EACxB,OACA,iBACA,eAAe,CAAC,GAChB,cAAc,WAEd,OAAO,IAAI,cAAc,OAAO,EAAE,YAAY,CAAC,IAAI,UAAU;CAC3D,MAAM,QAAQ,QAAQ,KAAK;CAC3B,OAAOA,QAAI,QAAQ,QAAQ,SACzBA,QAAI,IACF,0BAA0B,OAAO,KAAK,MAAM;EAC1C,UAAU;EACV,YAAY,KAAK,KAAK,SAAS,OAAO;EACtC;EACA;CACF,CAAC,GACD,SAAS,IAAI,CACf,CACF;AACF,CAAC;;AAGH,MAAM,UACH,SACA,EAAE,MAAM,MAAM,QAAQ,OAAO,cAC5B,GAAG,SAAS,KAAK,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,GAAG,EAAE,GAAG,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ;;AAGtF,MAAa,YAAY,MAA6B,QACpD,OAAO,MAAM,OAAO,MAAM,GAAG,GAAG;CAC9B,cAAc,OAAO,QAAQ;EAAE,QAAQ;EAAO,UAAU;CAAE,CAAC;CAC3D,SAAS,YACP,OAAO,IAAI,UAAU,OAAO,IAAI,aAAa;EAC3C,QAAQA,QAAI,IAAI,SAAS,OAAO,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;EAC7C,UAAUA,QAAI,KAAK,UAAU,EAAE,YAAY,UAAU,OAAO,IAAI,IAAI;CACtE,EAAE;AACN,CAAC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { t as Index } from "./workspace-pdw-fg9B.mjs";
|
|
2
|
+
import { Option } from "effect";
|
|
3
|
+
import { ParseOptions } from "@cosense-toolbox/parser";
|
|
4
|
+
import { CompletionItem, Definition, Position } from "vscode-languageserver/node";
|
|
5
|
+
//#region src/completion.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Where a completion applies, decided from the text alone.
|
|
8
|
+
*
|
|
9
|
+
* Cosense's own editor closes a bracket the moment it is typed, so a link is completed
|
|
10
|
+
* inside a *closed* pair rather than after an opening one. Everything here is pure: the
|
|
11
|
+
* candidates are somebody else's problem (see workspace.ts).
|
|
12
|
+
*/
|
|
13
|
+
interface CompletionDetection {
|
|
14
|
+
readonly kind: "link" | "hashtag";
|
|
15
|
+
readonly query: string;
|
|
16
|
+
readonly replaceStart: number;
|
|
17
|
+
readonly replaceEnd: number;
|
|
18
|
+
/** `replaceStart` up to the cursor — what the client sees as already typed. */
|
|
19
|
+
readonly typedText: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* True when the bracket holds a page link, or is on its way to one — the only bracket
|
|
23
|
+
* completion answers in.
|
|
24
|
+
*
|
|
25
|
+
* The other notations (`[* 見出し]`, `[$ x^2]`, `[taro.icon]`, `[ラベル https://…]`,
|
|
26
|
+
* `[a.png]`, `[/my-project/page]`) are left alone, because accepting a candidate replaces
|
|
27
|
+
* the whole bracket and would take the notation with it.
|
|
28
|
+
*/
|
|
29
|
+
declare const isLinkBracket: (inner: string) => boolean;
|
|
30
|
+
/** Pure line/cursor -> completion trigger detection. No AST, no I/O. */
|
|
31
|
+
declare const detectCompletion: (line: string, cursor: number) => Option.Option<CompletionDetection>;
|
|
32
|
+
/**
|
|
33
|
+
* `detectCompletion` for one line of a document, suppressed inside code. `parseOptions` are
|
|
34
|
+
* the site's notation extensions, parsed with as the site's build parses.
|
|
35
|
+
*/
|
|
36
|
+
declare const detectCompletionInDocument: (text: string, position: Position, parseOptions?: ParseOptions) => Option.Option<CompletionDetection>;
|
|
37
|
+
/**
|
|
38
|
+
* Title matching, so `[Side Kanban]` and `[side_kanban]` count as one page the way Cosense
|
|
39
|
+
* links them: case-insensitive, space and `_` alike, and NFKC so width variants match too.
|
|
40
|
+
*/
|
|
41
|
+
declare const normalizeForMatch: (s: string) => string;
|
|
42
|
+
/** The title as a tag writes it: Cosense folds a space to `_`. */
|
|
43
|
+
declare const asTagName: (title: string) => string;
|
|
44
|
+
/** A title that cannot be written as a tag at all is not offered as one. */
|
|
45
|
+
declare const isTaggable: (title: string) => boolean;
|
|
46
|
+
/** The pages that complete what is typed at `position`, or none outside a link or a tag. */
|
|
47
|
+
declare const completionItems: (index: Index, text: string, position: Position, parseOptions?: ParseOptions) => CompletionItem[];
|
|
48
|
+
/** The file of the page named at `position`, opened at its top. None for a missing page. */
|
|
49
|
+
declare const definitionOf: (index: Index, text: string, position: Position, parseOptions?: ParseOptions) => Option.Option<Definition>;
|
|
50
|
+
//#endregion
|
|
51
|
+
export { CompletionDetection, asTagName, completionItems, definitionOf, detectCompletion, detectCompletionInDocument, isLinkBracket, isTaggable, normalizeForMatch };
|
|
52
|
+
//# sourceMappingURL=completion.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"completion.d.mts","names":[],"sources":["../src/completion.ts"],"mappings":";;;;;;;;;;;;UAmBiB;WACN;WACA;WACA;WACA;;WAEA;;;;;;;;;;cAiCE,gBAAiB;;cAgEjB,mBACX,cACA,mBACC,OAAO,OAAO;;;;;cAqCJ,6BACX,cACA,UAAU,UACV,eAAc,iBACb,OAAO,OAAO;;;;;cAaJ,oBAAqB;;cAIrB,YAAa;;cAGb,aAAc;;cAoDd,kBACX,OAAO,OACP,cACA,UAAU,UACV,eAAc,iBACb;;cAUU,eACX,OAAO,OACP,cACA,UAAU,UACV,eAAc,iBACb,OAAO,OAAO"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Array, Match, Option, pipe } from "effect";
|
|
2
|
+
import { normalizeLineEndings, parse } from "@cosense-toolbox/parser";
|
|
3
|
+
import { collect } from "@cosense-toolbox/parser/utils";
|
|
4
|
+
import { CompletionItemKind } from "vscode-languageserver/node";
|
|
5
|
+
//#region src/completion.ts
|
|
6
|
+
/**
|
|
7
|
+
* The notations a bracket can hold other than a page link: a formula, a project link, a
|
|
8
|
+
* decoration, an icon, a URL, an image path.
|
|
9
|
+
*/
|
|
10
|
+
const OTHER_NOTATIONS = [
|
|
11
|
+
/^\$/,
|
|
12
|
+
/^\//,
|
|
13
|
+
/^[!"#%&'()*+,\-./{|}<>_~]+(?:\s|$)/,
|
|
14
|
+
/^.+\.icon(?:\*\d+)?$/,
|
|
15
|
+
/https?:\/\//i,
|
|
16
|
+
/\.(png|jpe?g|gif|webp|svg|bmp|avif)$/i
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* True when the bracket holds a page link, or is on its way to one — the only bracket
|
|
20
|
+
* completion answers in.
|
|
21
|
+
*
|
|
22
|
+
* The other notations (`[* 見出し]`, `[$ x^2]`, `[taro.icon]`, `[ラベル https://…]`,
|
|
23
|
+
* `[a.png]`, `[/my-project/page]`) are left alone, because accepting a candidate replaces
|
|
24
|
+
* the whole bracket and would take the notation with it.
|
|
25
|
+
*/
|
|
26
|
+
const isLinkBracket = (inner) => inner === "" || !Array.some(OTHER_NOTATIONS, (notation) => notation.test(inner));
|
|
27
|
+
const isBracket = (char) => char === "[" || char === "]";
|
|
28
|
+
/**
|
|
29
|
+
* The `[` that opens the bracket around `cursor`: the nearest bracket to its left, if that
|
|
30
|
+
* is an opening one. A `[` that is the second half of `[[` opens bold or a large image, not
|
|
31
|
+
* a link.
|
|
32
|
+
*/
|
|
33
|
+
const openingBefore = (line, cursor) => pipe(line.slice(0, cursor).split(""), Array.findLastIndex(isBracket), Option.filter((open) => line[open] === "[" && line[open - 1] !== "["));
|
|
34
|
+
/** The `]` that closes it: the nearest bracket to the right of `cursor`, if a closing one. */
|
|
35
|
+
const closingAfter = (line, cursor) => pipe(line.slice(cursor).split(""), Array.findFirstIndex(isBracket), Option.map((offset) => cursor + offset), Option.filter((close) => line[close] === "]"));
|
|
36
|
+
/** Only inside a *closed* pair, matching Cosense. */
|
|
37
|
+
const detectLink = (line, cursor) => pipe(Option.all([openingBefore(line, cursor), closingAfter(line, cursor)]), Option.map(([open, close]) => ({
|
|
38
|
+
open,
|
|
39
|
+
close,
|
|
40
|
+
inner: line.slice(open + 1, close)
|
|
41
|
+
})), Option.filter(({ inner }) => isLinkBracket(inner)), Option.map(({ open, close, inner }) => ({
|
|
42
|
+
kind: "link",
|
|
43
|
+
query: inner,
|
|
44
|
+
replaceStart: open,
|
|
45
|
+
replaceEnd: close + 1,
|
|
46
|
+
typedText: line.slice(open, cursor)
|
|
47
|
+
})));
|
|
48
|
+
/**
|
|
49
|
+
* A `#` at a tag boundary (the line's start, or after whitespace) and the tag name typed
|
|
50
|
+
* after it up to the cursor, mirroring the parser's hashtag construct.
|
|
51
|
+
*/
|
|
52
|
+
const TAG_BEFORE_CURSOR = /(?:^|(?<=\s))#([^\s[\]#]*)$/;
|
|
53
|
+
const detectHashtag = (line, cursor) => pipe(Option.fromNullable(TAG_BEFORE_CURSOR.exec(line.slice(0, cursor))), Option.map(({ index, 1: query = "" }) => ({
|
|
54
|
+
kind: "hashtag",
|
|
55
|
+
query,
|
|
56
|
+
replaceStart: index,
|
|
57
|
+
replaceEnd: cursor,
|
|
58
|
+
typedText: line.slice(index, cursor)
|
|
59
|
+
})));
|
|
60
|
+
/** Pure line/cursor -> completion trigger detection. No AST, no I/O. */
|
|
61
|
+
const detectCompletion = (line, cursor) => pipe(detectLink(line, cursor), Option.orElse(() => detectHashtag(line, cursor)));
|
|
62
|
+
/**
|
|
63
|
+
* Inside code, a bracket is not notation. The cursor must be strictly between the
|
|
64
|
+
* backticks (not touching them) to count as inside an inline span.
|
|
65
|
+
*/
|
|
66
|
+
const isInCode = (text, { line, character }, parseOptions) => {
|
|
67
|
+
const page = parse(text, parseOptions);
|
|
68
|
+
return Array.some(collect(page, "codeBlock"), ({ position }) => line >= position.start.line && line <= position.end.line) || Array.some(collect(page, "inlineCode"), ({ position }) => line === position.start.line && character > position.start.column && character < position.end.column);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* `detectCompletion` for one line of a document, suppressed inside code. `parseOptions` are
|
|
72
|
+
* the site's notation extensions, parsed with as the site's build parses.
|
|
73
|
+
*/
|
|
74
|
+
const detectCompletionInDocument = (text, position, parseOptions = {}) => pipe(Option.liftPredicate(text, (document) => !isInCode(document, position, parseOptions)), Option.map((document) => normalizeLineEndings(document).split("\n")[position.line] ?? ""), Option.flatMap((line) => detectCompletion(line, position.character)));
|
|
75
|
+
/**
|
|
76
|
+
* Title matching, so `[Side Kanban]` and `[side_kanban]` count as one page the way Cosense
|
|
77
|
+
* links them: case-insensitive, space and `_` alike, and NFKC so width variants match too.
|
|
78
|
+
*/
|
|
79
|
+
const normalizeForMatch = (s) => s.normalize("NFKC").toLowerCase().replace(/[_ ]/g, " ");
|
|
80
|
+
/** The title as a tag writes it: Cosense folds a space to `_`. */
|
|
81
|
+
const asTagName = (title) => title.replace(/ /g, "_");
|
|
82
|
+
/** A title that cannot be written as a tag at all is not offered as one. */
|
|
83
|
+
const isTaggable = (title) => !/[\s[\]#]/.test(asTagName(title));
|
|
84
|
+
/** The last part of a `/`-separated location. */
|
|
85
|
+
const fileNameOf = (location) => Option.getOrElse(Array.last(location.split("/")), () => location);
|
|
86
|
+
/**
|
|
87
|
+
* A page as a candidate for `detection`, or None when it does not fit: a tag cannot hold a
|
|
88
|
+
* space or a bracket, so a page whose title does is not offered after `#` (accepting it
|
|
89
|
+
* would write something that reads as two tags).
|
|
90
|
+
*/
|
|
91
|
+
const candidate = (detection, line) => (page) => {
|
|
92
|
+
const query = normalizeForMatch(detection.query);
|
|
93
|
+
const normalized = normalizeForMatch(page.title);
|
|
94
|
+
const notation = Match.value(detection.kind).pipe(Match.when("hashtag", () => `#${asTagName(page.title)}`), Match.when("link", () => `[${page.title}]`), Match.exhaustive);
|
|
95
|
+
return pipe(Option.some(page), Option.filter(() => detection.kind === "link" || isTaggable(page.title)), Option.filter(() => query === "" || normalized.includes(query)), Option.map(() => ({
|
|
96
|
+
label: page.title,
|
|
97
|
+
kind: CompletionItemKind.File,
|
|
98
|
+
detail: fileNameOf(page.location),
|
|
99
|
+
documentation: page.location,
|
|
100
|
+
textEdit: {
|
|
101
|
+
range: {
|
|
102
|
+
start: {
|
|
103
|
+
line,
|
|
104
|
+
character: detection.replaceStart
|
|
105
|
+
},
|
|
106
|
+
end: {
|
|
107
|
+
line,
|
|
108
|
+
character: detection.replaceEnd
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
newText: notation
|
|
112
|
+
},
|
|
113
|
+
filterText: notation,
|
|
114
|
+
sortText: `${normalized} ${page.location}`
|
|
115
|
+
})));
|
|
116
|
+
};
|
|
117
|
+
/** The pages that complete what is typed at `position`, or none outside a link or a tag. */
|
|
118
|
+
const completionItems = (index, text, position, parseOptions = {}) => pipe(detectCompletionInDocument(text, position, parseOptions), Option.match({
|
|
119
|
+
onNone: () => [],
|
|
120
|
+
onSome: (detection) => Array.filterMap(index.pages, candidate(detection, position.line))
|
|
121
|
+
}));
|
|
122
|
+
/** The file of the page named at `position`, opened at its top. None for a missing page. */
|
|
123
|
+
const definitionOf = (index, text, position, parseOptions = {}) => pipe(detectCompletionInDocument(text, position, parseOptions), Option.map(({ query }) => normalizeForMatch(query)), Option.flatMap((key) => Array.findFirst(index.pages, (page) => normalizeForMatch(page.title) === key)), Option.map(({ uri }) => {
|
|
124
|
+
const start = {
|
|
125
|
+
line: 0,
|
|
126
|
+
character: 0
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
uri,
|
|
130
|
+
range: {
|
|
131
|
+
start,
|
|
132
|
+
end: start
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}));
|
|
136
|
+
//#endregion
|
|
137
|
+
export { asTagName, completionItems, definitionOf, detectCompletion, detectCompletionInDocument, isLinkBracket, isTaggable, normalizeForMatch };
|
|
138
|
+
|
|
139
|
+
//# sourceMappingURL=completion.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"completion.mjs","names":["Arr"],"sources":["../src/completion.ts"],"sourcesContent":["import { normalizeLineEndings, parse, type ParseOptions } from \"@cosense-toolbox/parser\"\nimport { collect } from \"@cosense-toolbox/parser/utils\"\nimport { Array as Arr, Match, Option, pipe } from \"effect\"\nimport {\n type CompletionItem,\n CompletionItemKind,\n type Definition,\n type Position,\n} from \"vscode-languageserver/node\"\n\nimport type { Index, Page } from \"./workspace\"\n\n/**\n * Where a completion applies, decided from the text alone.\n *\n * Cosense's own editor closes a bracket the moment it is typed, so a link is completed\n * inside a *closed* pair rather than after an opening one. Everything here is pure: the\n * candidates are somebody else's problem (see workspace.ts).\n */\nexport interface CompletionDetection {\n readonly kind: \"link\" | \"hashtag\"\n readonly query: string\n readonly replaceStart: number\n readonly replaceEnd: number\n /** `replaceStart` up to the cursor — what the client sees as already typed. */\n readonly typedText: string\n}\n\n// --- Which bracket is a link --------------------------------------------------------------\n\n/**\n * A marker run followed by whitespace, or by nothing at all: the parser waits for a body,\n * but a menu that opens between `[* ` and its text is the flicker this rule exists to stop.\n * The markers are every character Cosense accepts in a decoration, from help-jp/文字装飾記法.\n */\nconst STARTS_DECORATION = /^[!\"#%&'()*+,\\-./{|}<>_~]+(?:\\s|$)/\n\n/**\n * The notations a bracket can hold other than a page link: a formula, a project link, a\n * decoration, an icon, a URL, an image path.\n */\nconst OTHER_NOTATIONS: ReadonlyArray<RegExp> = [\n /^\\$/,\n /^\\//,\n STARTS_DECORATION,\n /^.+\\.icon(?:\\*\\d+)?$/,\n /https?:\\/\\//i,\n /\\.(png|jpe?g|gif|webp|svg|bmp|avif)$/i,\n]\n\n/**\n * True when the bracket holds a page link, or is on its way to one — the only bracket\n * completion answers in.\n *\n * The other notations (`[* 見出し]`, `[$ x^2]`, `[taro.icon]`, `[ラベル https://…]`,\n * `[a.png]`, `[/my-project/page]`) are left alone, because accepting a candidate replaces\n * the whole bracket and would take the notation with it.\n */\nexport const isLinkBracket = (inner: string): boolean =>\n inner === \"\" || !Arr.some(OTHER_NOTATIONS, (notation) => notation.test(inner))\n\n// --- Where the cursor is ------------------------------------------------------------------\n\nconst isBracket = (char: string): boolean => char === \"[\" || char === \"]\"\n\n/**\n * The `[` that opens the bracket around `cursor`: the nearest bracket to its left, if that\n * is an opening one. A `[` that is the second half of `[[` opens bold or a large image, not\n * a link.\n */\nconst openingBefore = (line: string, cursor: number): Option.Option<number> =>\n pipe(\n line.slice(0, cursor).split(\"\"),\n Arr.findLastIndex(isBracket),\n Option.filter((open) => line[open] === \"[\" && line[open - 1] !== \"[\"),\n )\n\n/** The `]` that closes it: the nearest bracket to the right of `cursor`, if a closing one. */\nconst closingAfter = (line: string, cursor: number): Option.Option<number> =>\n pipe(\n line.slice(cursor).split(\"\"),\n Arr.findFirstIndex(isBracket),\n Option.map((offset) => cursor + offset),\n Option.filter((close) => line[close] === \"]\"),\n )\n\n/** Only inside a *closed* pair, matching Cosense. */\nconst detectLink = (line: string, cursor: number): Option.Option<CompletionDetection> =>\n pipe(\n Option.all([openingBefore(line, cursor), closingAfter(line, cursor)]),\n // The whole bracket content, not just up to the cursor: Cosense treats the link text\n // as one unit, so the candidates are the same wherever the cursor sits.\n Option.map(([open, close]) => ({ open, close, inner: line.slice(open + 1, close) })),\n Option.filter(({ inner }) => isLinkBracket(inner)),\n Option.map(({ open, close, inner }) => ({\n kind: \"link\",\n query: inner,\n replaceStart: open,\n replaceEnd: close + 1,\n typedText: line.slice(open, cursor),\n })),\n )\n\n/**\n * A `#` at a tag boundary (the line's start, or after whitespace) and the tag name typed\n * after it up to the cursor, mirroring the parser's hashtag construct.\n */\nconst TAG_BEFORE_CURSOR = /(?:^|(?<=\\s))#([^\\s[\\]#]*)$/\n\nconst detectHashtag = (line: string, cursor: number): Option.Option<CompletionDetection> =>\n pipe(\n Option.fromNullable(TAG_BEFORE_CURSOR.exec(line.slice(0, cursor))),\n Option.map(({ index, 1: query = \"\" }) => ({\n kind: \"hashtag\",\n query,\n replaceStart: index,\n replaceEnd: cursor,\n typedText: line.slice(index, cursor),\n })),\n )\n\n/** Pure line/cursor -> completion trigger detection. No AST, no I/O. */\nexport const detectCompletion = (\n line: string,\n cursor: number,\n): Option.Option<CompletionDetection> =>\n pipe(\n detectLink(line, cursor),\n Option.orElse(() => detectHashtag(line, cursor)),\n )\n\n// --- In a document ------------------------------------------------------------------------\n\n/**\n * Inside code, a bracket is not notation. The cursor must be strictly between the\n * backticks (not touching them) to count as inside an inline span.\n */\nconst isInCode = (\n text: string,\n { line, character }: Position,\n parseOptions: ParseOptions,\n): boolean => {\n const page = parse(text, parseOptions)\n return (\n Arr.some(\n collect(page, \"codeBlock\"),\n ({ position }) => line >= position.start.line && line <= position.end.line,\n ) ||\n Arr.some(\n collect(page, \"inlineCode\"),\n ({ position }) =>\n line === position.start.line &&\n character > position.start.column &&\n character < position.end.column,\n )\n )\n}\n\n/**\n * `detectCompletion` for one line of a document, suppressed inside code. `parseOptions` are\n * the site's notation extensions, parsed with as the site's build parses.\n */\nexport const detectCompletionInDocument = (\n text: string,\n position: Position,\n parseOptions: ParseOptions = {},\n): Option.Option<CompletionDetection> =>\n pipe(\n Option.liftPredicate(text, (document) => !isInCode(document, position, parseOptions)),\n Option.map((document) => normalizeLineEndings(document).split(\"\\n\")[position.line] ?? \"\"),\n Option.flatMap((line) => detectCompletion(line, position.character)),\n )\n\n// --- Matching titles ----------------------------------------------------------------------\n\n/**\n * Title matching, so `[Side Kanban]` and `[side_kanban]` count as one page the way Cosense\n * links them: case-insensitive, space and `_` alike, and NFKC so width variants match too.\n */\nexport const normalizeForMatch = (s: string): string =>\n s.normalize(\"NFKC\").toLowerCase().replace(/[_ ]/g, \" \")\n\n/** The title as a tag writes it: Cosense folds a space to `_`. */\nexport const asTagName = (title: string): string => title.replace(/ /g, \"_\")\n\n/** A title that cannot be written as a tag at all is not offered as one. */\nexport const isTaggable = (title: string): boolean => !/[\\s[\\]#]/.test(asTagName(title))\n\n// --- Answers ------------------------------------------------------------------------------\n\n/** The last part of a `/`-separated location. */\nconst fileNameOf = (location: string): string =>\n Option.getOrElse(Arr.last(location.split(\"/\")), () => location)\n\n/**\n * A page as a candidate for `detection`, or None when it does not fit: a tag cannot hold a\n * space or a bracket, so a page whose title does is not offered after `#` (accepting it\n * would write something that reads as two tags).\n */\nconst candidate =\n (detection: CompletionDetection, line: number) =>\n (page: Page): Option.Option<CompletionItem> => {\n const query = normalizeForMatch(detection.query)\n const normalized = normalizeForMatch(page.title)\n const notation = Match.value(detection.kind).pipe(\n Match.when(\"hashtag\", () => `#${asTagName(page.title)}`),\n Match.when(\"link\", () => `[${page.title}]`),\n Match.exhaustive,\n )\n return pipe(\n Option.some(page),\n Option.filter(() => detection.kind === \"link\" || isTaggable(page.title)),\n Option.filter(() => query === \"\" || normalized.includes(query)),\n Option.map((): CompletionItem => ({\n label: page.title,\n kind: CompletionItemKind.File,\n // The file name alone: a whole path is cut off in the menu before the name shows.\n detail: fileNameOf(page.location),\n // The whole path, where the editor shows more room once the item is selected.\n documentation: page.location,\n // The whole notation is replaced, brackets included: the reader typed the `[`,\n // and leaving it in place would give `[[title]]`.\n textEdit: {\n range: {\n start: { line, character: detection.replaceStart },\n end: { line, character: detection.replaceEnd },\n },\n newText: notation,\n },\n // A client filters by the text from the edit's start to the cursor, `[設` here, so\n // it has to find that in the notation rather than in the bare title.\n filterText: notation,\n sortText: `${normalized} ${page.location}`,\n })),\n )\n }\n\n/** The pages that complete what is typed at `position`, or none outside a link or a tag. */\nexport const completionItems = (\n index: Index,\n text: string,\n position: Position,\n parseOptions: ParseOptions = {},\n): CompletionItem[] =>\n pipe(\n detectCompletionInDocument(text, position, parseOptions),\n Option.match({\n onNone: () => [],\n onSome: (detection) => Arr.filterMap(index.pages, candidate(detection, position.line)),\n }),\n )\n\n/** The file of the page named at `position`, opened at its top. None for a missing page. */\nexport const definitionOf = (\n index: Index,\n text: string,\n position: Position,\n parseOptions: ParseOptions = {},\n): Option.Option<Definition> =>\n pipe(\n detectCompletionInDocument(text, position, parseOptions),\n Option.map(({ query }) => normalizeForMatch(query)),\n Option.flatMap((key) =>\n Arr.findFirst(index.pages, (page) => normalizeForMatch(page.title) === key),\n ),\n Option.map(({ uri }) => {\n const start = { line: 0, character: 0 }\n return { uri, range: { start, end: start } }\n }),\n )\n"],"mappings":"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"}
|
package/dist/main.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/main.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Effect, Match, pipe } from "effect";
|
|
3
|
+
//#region src/main.ts
|
|
4
|
+
/**
|
|
5
|
+
* `csn-lsp`: the language server, or with `check` as its first argument, a one-off check of
|
|
6
|
+
* a site's pages that exits with a code a build can act on.
|
|
7
|
+
*
|
|
8
|
+
* Each is loaded only when asked for: the server opens its connection as it loads, which a
|
|
9
|
+
* check must not do.
|
|
10
|
+
*/
|
|
11
|
+
const [command, ...args] = process.argv.slice(2);
|
|
12
|
+
const check = pipe(Effect.promise(() => import("./check.mjs")), Effect.flatMap(({ runCheck }) => runCheck(args, process.cwd())), Effect.tap(({ output, exitCode }) => Effect.sync(() => {
|
|
13
|
+
(exitCode === 2 ? process.stderr : process.stdout).write(output);
|
|
14
|
+
process.exitCode = exitCode;
|
|
15
|
+
})));
|
|
16
|
+
const serve = Effect.promise(() => import("./server-DPPxtJ6Y.mjs"));
|
|
17
|
+
Effect.runFork(Match.value(command).pipe(Match.when("check", () => Effect.asVoid(check)), Match.orElse(() => Effect.asVoid(serve))));
|
|
18
|
+
//#endregion
|
|
19
|
+
export {};
|
|
20
|
+
|
|
21
|
+
//# sourceMappingURL=main.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main.mjs","names":[],"sources":["../src/main.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * `csn-lsp`: the language server, or with `check` as its first argument, a one-off check of\n * a site's pages that exits with a code a build can act on.\n *\n * Each is loaded only when asked for: the server opens its connection as it loads, which a\n * check must not do.\n */\nimport { Effect, Match, pipe } from \"effect\"\n\nconst [command, ...args] = process.argv.slice(2)\n\nconst check = pipe(\n Effect.promise(() => import(\"./check\")),\n Effect.flatMap(({ runCheck }) => runCheck(args, process.cwd())),\n Effect.tap(({ output, exitCode }) =>\n Effect.sync(() => {\n // A usage error goes where errors go; reports are the command's output.\n ;(exitCode === 2 ? process.stderr : process.stdout).write(output)\n process.exitCode = exitCode\n }),\n ),\n)\n\nconst serve = Effect.promise(() => import(\"./server\"))\n\nEffect.runFork(\n Match.value(command).pipe(\n Match.when(\"check\", () => Effect.asVoid(check)),\n Match.orElse(() => Effect.asVoid(serve)),\n ),\n)\n"],"mappings":";;;;;;;;;;AAUA,MAAM,CAAC,SAAS,GAAG,QAAQ,QAAQ,KAAK,MAAM,CAAC;AAE/C,MAAM,QAAQ,KACZ,OAAO,cAAc,OAAO,cAAU,GACtC,OAAO,SAAS,EAAE,eAAe,SAAS,MAAM,QAAQ,IAAI,CAAC,CAAC,GAC9D,OAAO,KAAK,EAAE,QAAQ,eACpB,OAAO,WAAW;CAEf,CAAC,aAAa,IAAI,QAAQ,SAAS,QAAQ,OAAA,CAAQ,MAAM,MAAM;CAChE,QAAQ,WAAW;AACrB,CAAC,CACH,CACF;AAEA,MAAM,QAAQ,OAAO,cAAc,OAAO,wBAAW;AAErD,OAAO,QACL,MAAM,MAAM,OAAO,CAAC,CAAC,KACnB,MAAM,KAAK,eAAe,OAAO,OAAO,KAAK,CAAC,GAC9C,MAAM,aAAa,OAAO,OAAO,KAAK,CAAC,CACzC,CACF"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { a as encodeTokens, i as computeTokens, n as LEGEND } from "./tokens-D0NP_uLo.mjs";
|
|
2
|
+
import { completionItems, definitionOf } from "./completion.mjs";
|
|
3
|
+
import { a as rootsOf, c as settingsOf, o as defaultSettings, r as readIndex, s as parseOptionsOf, t as emptyIndex, u as unresolvedLinkDiagnostics } from "./workspace-DouAOZnK.mjs";
|
|
4
|
+
import { Array, Effect, Option, Ref, pipe } from "effect";
|
|
5
|
+
import { ProposedFeatures, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node";
|
|
6
|
+
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
7
|
+
//#region src/server.ts
|
|
8
|
+
const connection = createConnection(ProposedFeatures.all);
|
|
9
|
+
const documents = new TextDocuments(TextDocument);
|
|
10
|
+
/** The language ids editors give `.csnx`. Not every editor names the language the same way. */
|
|
11
|
+
const CSNX_LANGUAGE_IDS = ["csnx", "cosense-x"];
|
|
12
|
+
/**
|
|
13
|
+
* `.csnx` is `.csn` plus component lines, so the format is decided per document. The
|
|
14
|
+
* language id the editor sends is trusted first; a file:// URI's suffix is the fallback.
|
|
15
|
+
*/
|
|
16
|
+
const readsComponents = (document) => Array.contains(CSNX_LANGUAGE_IDS, document.languageId) || document.uri.endsWith(".csnx");
|
|
17
|
+
/** What the reader set in the editor's initialization options (see settings.ts). */
|
|
18
|
+
const settings = Ref.unsafeMake(defaultSettings);
|
|
19
|
+
const currentSettings = () => Effect.runSync(Ref.get(settings));
|
|
20
|
+
/** How to parse, so notation reads as the site's build reads it. */
|
|
21
|
+
const currentParseOptions = () => parseOptionsOf(currentSettings());
|
|
22
|
+
const semanticTokensOf = (document) => ({ data: encodeTokens(computeTokens(document.getText(), {
|
|
23
|
+
components: readsComponents(document),
|
|
24
|
+
parseOptions: currentParseOptions(),
|
|
25
|
+
frontmatter: currentSettings().frontmatter
|
|
26
|
+
})) });
|
|
27
|
+
/**
|
|
28
|
+
* `answer` for the open document at `uri`, or `fallback` when the editor asks about one it
|
|
29
|
+
* has not synced yet.
|
|
30
|
+
*/
|
|
31
|
+
const withDocument = (uri, answer, fallback) => pipe(Option.fromNullable(documents.get(uri)), Option.map(answer), Option.getOrElse(() => fallback));
|
|
32
|
+
/**
|
|
33
|
+
* Every page the workspace holds. Read once at initialize and again when a page is saved,
|
|
34
|
+
* because a title only changes by someone writing it, and re-reading on every keystroke
|
|
35
|
+
* would walk the whole tree while the reader is typing.
|
|
36
|
+
*/
|
|
37
|
+
const index = Ref.unsafeMake(Option.none());
|
|
38
|
+
const roots = Ref.unsafeMake([]);
|
|
39
|
+
/** The index as it stands; empty until it has been read once. */
|
|
40
|
+
const currentIndex = () => Option.getOrElse(Effect.runSync(Ref.get(index)), () => emptyIndex);
|
|
41
|
+
/**
|
|
42
|
+
* Sends `document`'s links to missing pages. Nothing until the index has been read once:
|
|
43
|
+
* against an empty index every link would be flagged, only to be cleared a moment later.
|
|
44
|
+
*/
|
|
45
|
+
const publishDiagnostics = (document) => pipe(Effect.all([Ref.get(index), Ref.get(settings)]), Effect.flatMap(([read, set]) => Option.match(read, {
|
|
46
|
+
onNone: () => Effect.void,
|
|
47
|
+
onSome: (pages) => Effect.promise(() => connection.sendDiagnostics({
|
|
48
|
+
uri: document.uri,
|
|
49
|
+
diagnostics: unresolvedLinkDiagnostics(pages, document.getText(), {
|
|
50
|
+
severity: set.unresolvedLinks,
|
|
51
|
+
components: readsComponents(document),
|
|
52
|
+
parseOptions: parseOptionsOf(set),
|
|
53
|
+
frontmatter: set.frontmatter
|
|
54
|
+
})
|
|
55
|
+
}))
|
|
56
|
+
})));
|
|
57
|
+
/**
|
|
58
|
+
* Reads the index again in the background; requests meanwhile answer from the last one.
|
|
59
|
+
* A page may have appeared or gone, so every open document is checked again after.
|
|
60
|
+
*/
|
|
61
|
+
const refreshIndex = () => {
|
|
62
|
+
pipe(Effect.all([Ref.get(roots), Ref.get(settings)]), Effect.flatMap(([at, set]) => readIndex(at, { frontmatter: set.frontmatter })), Effect.flatMap((next) => Ref.set(index, Option.some(next))), Effect.flatMap(() => Effect.forEach(documents.all(), publishDiagnostics, { discard: true })), Effect.runFork);
|
|
63
|
+
};
|
|
64
|
+
connection.onInitialize(({ workspaceFolders, initializationOptions }) => {
|
|
65
|
+
const read = settingsOf(initializationOptions);
|
|
66
|
+
Effect.runSync(Ref.set(settings, read));
|
|
67
|
+
Effect.runSync(Ref.set(roots, rootsOf(workspaceFolders, read.sources)));
|
|
68
|
+
refreshIndex();
|
|
69
|
+
return { capabilities: {
|
|
70
|
+
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
71
|
+
semanticTokensProvider: {
|
|
72
|
+
legend: {
|
|
73
|
+
tokenTypes: [...LEGEND],
|
|
74
|
+
tokenModifiers: []
|
|
75
|
+
},
|
|
76
|
+
full: true
|
|
77
|
+
},
|
|
78
|
+
completionProvider: { triggerCharacters: ["[", "#"] },
|
|
79
|
+
definitionProvider: true
|
|
80
|
+
} };
|
|
81
|
+
});
|
|
82
|
+
documents.onDidSave(refreshIndex);
|
|
83
|
+
documents.onDidChangeContent(({ document }) => {
|
|
84
|
+
Effect.runFork(publishDiagnostics(document));
|
|
85
|
+
});
|
|
86
|
+
documents.onDidClose(({ document }) => {
|
|
87
|
+
Effect.runFork(Effect.promise(() => connection.sendDiagnostics({
|
|
88
|
+
uri: document.uri,
|
|
89
|
+
diagnostics: []
|
|
90
|
+
})));
|
|
91
|
+
});
|
|
92
|
+
connection.onCompletion(({ textDocument: { uri }, position }) => withDocument(uri, (document) => completionItems(currentIndex(), document.getText(), position, currentParseOptions()), []));
|
|
93
|
+
connection.onDefinition(({ textDocument: { uri }, position }) => withDocument(uri, (document) => Option.getOrNull(definitionOf(currentIndex(), document.getText(), position, currentParseOptions())), null));
|
|
94
|
+
connection.languages.semanticTokens.on(({ textDocument: { uri } }) => withDocument(uri, semanticTokensOf, { data: [] }));
|
|
95
|
+
documents.listen(connection);
|
|
96
|
+
connection.listen();
|
|
97
|
+
//#endregion
|
|
98
|
+
export {};
|
|
99
|
+
|
|
100
|
+
//# sourceMappingURL=server-DPPxtJ6Y.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-DPPxtJ6Y.mjs","names":["Arr"],"sources":["../src/server.ts"],"sourcesContent":["import { Array as Arr, Effect, Option, pipe, Ref } from \"effect\"\nimport {\n type CompletionItem,\n createConnection,\n type Definition,\n type InitializeResult,\n ProposedFeatures,\n type SemanticTokens,\n TextDocuments,\n TextDocumentSyncKind,\n} from \"vscode-languageserver/node\"\nimport { TextDocument } from \"vscode-languageserver-textdocument\"\n\nimport { completionItems, definitionOf } from \"./completion\"\nimport { unresolvedLinkDiagnostics } from \"./diagnostics\"\nimport { defaultSettings, parseOptionsOf, settingsOf } from \"./settings\"\nimport { computeTokens, encodeTokens, LEGEND } from \"./tokens\"\nimport { emptyIndex, type Index, readIndex, rootsOf } from \"./workspace\"\n\nconst connection = createConnection(ProposedFeatures.all)\nconst documents = new TextDocuments(TextDocument)\n\n/** The language ids editors give `.csnx`. Not every editor names the language the same way. */\nconst CSNX_LANGUAGE_IDS: ReadonlyArray<string> = [\"csnx\", \"cosense-x\"]\n\n/**\n * `.csnx` is `.csn` plus component lines, so the format is decided per document. The\n * language id the editor sends is trusted first; a file:// URI's suffix is the fallback.\n */\nconst readsComponents = (document: TextDocument): boolean =>\n Arr.contains(CSNX_LANGUAGE_IDS, document.languageId) || document.uri.endsWith(\".csnx\")\n\n// --- Settings -----------------------------------------------------------------------------\n\n/** What the reader set in the editor's initialization options (see settings.ts). */\nconst settings = Ref.unsafeMake(defaultSettings)\n\nconst currentSettings = () => Effect.runSync(Ref.get(settings))\n\n/** How to parse, so notation reads as the site's build reads it. */\nconst currentParseOptions = () => parseOptionsOf(currentSettings())\n\nconst semanticTokensOf = (document: TextDocument): SemanticTokens => ({\n data: encodeTokens(\n computeTokens(document.getText(), {\n components: readsComponents(document),\n parseOptions: currentParseOptions(),\n frontmatter: currentSettings().frontmatter,\n }),\n ),\n})\n\n/**\n * `answer` for the open document at `uri`, or `fallback` when the editor asks about one it\n * has not synced yet.\n */\nconst withDocument = <A>(uri: string, answer: (document: TextDocument) => A, fallback: A): A =>\n pipe(\n Option.fromNullable(documents.get(uri)),\n Option.map(answer),\n Option.getOrElse(() => fallback),\n )\n\n// --- The workspace index --------------------------------------------------------------------\n\n/**\n * Every page the workspace holds. Read once at initialize and again when a page is saved,\n * because a title only changes by someone writing it, and re-reading on every keystroke\n * would walk the whole tree while the reader is typing.\n */\nconst index = Ref.unsafeMake<Option.Option<Index>>(Option.none())\nconst roots = Ref.unsafeMake<ReadonlyArray<string>>([])\n\n/** The index as it stands; empty until it has been read once. */\nconst currentIndex = (): Index => Option.getOrElse(Effect.runSync(Ref.get(index)), () => emptyIndex)\n\n// --- Diagnostics --------------------------------------------------------------------------\n\n/**\n * Sends `document`'s links to missing pages. Nothing until the index has been read once:\n * against an empty index every link would be flagged, only to be cleared a moment later.\n */\nconst publishDiagnostics = (document: TextDocument): Effect.Effect<void> =>\n pipe(\n Effect.all([Ref.get(index), Ref.get(settings)]),\n Effect.flatMap(([read, set]) =>\n Option.match(read, {\n onNone: () => Effect.void,\n onSome: (pages) =>\n Effect.promise(() =>\n connection.sendDiagnostics({\n uri: document.uri,\n diagnostics: unresolvedLinkDiagnostics(pages, document.getText(), {\n severity: set.unresolvedLinks,\n components: readsComponents(document),\n parseOptions: parseOptionsOf(set),\n frontmatter: set.frontmatter,\n }),\n }),\n ),\n }),\n ),\n )\n\n/**\n * Reads the index again in the background; requests meanwhile answer from the last one.\n * A page may have appeared or gone, so every open document is checked again after.\n */\nconst refreshIndex = (): void => {\n pipe(\n Effect.all([Ref.get(roots), Ref.get(settings)]),\n Effect.flatMap(([at, set]) => readIndex(at, { frontmatter: set.frontmatter })),\n Effect.flatMap((next) => Ref.set(index, Option.some(next))),\n Effect.flatMap(() => Effect.forEach(documents.all(), publishDiagnostics, { discard: true })),\n Effect.runFork,\n )\n}\n\n// --- Handlers -----------------------------------------------------------------------------\n\nconnection.onInitialize(({ workspaceFolders, initializationOptions }): InitializeResult => {\n const read = settingsOf(initializationOptions)\n Effect.runSync(Ref.set(settings, read))\n Effect.runSync(Ref.set(roots, rootsOf(workspaceFolders, read.sources)))\n refreshIndex()\n return {\n capabilities: {\n // Incremental: a page is one file the reader types into, and resending all of it on\n // every keystroke is what makes a large page feel slow.\n textDocumentSync: TextDocumentSyncKind.Incremental,\n semanticTokensProvider: {\n legend: { tokenTypes: [...LEGEND], tokenModifiers: [] },\n full: true,\n },\n // `[` and `#` open a link and a tag. The editor asks again on every character after\n // one, so nothing else has to be declared for typing to keep the menu up to date.\n completionProvider: { triggerCharacters: [\"[\", \"#\"] },\n definitionProvider: true,\n },\n }\n})\n\n// A title only changes when a page is written, so the index is rebuilt then rather than\n// on a timer or on every request.\ndocuments.onDidSave(refreshIndex)\n\n// Opening a document counts as a change, so this covers both.\ndocuments.onDidChangeContent(({ document }) => {\n Effect.runFork(publishDiagnostics(document))\n})\n\n// A closed document's diagnostics would otherwise linger in the editor's problem list.\ndocuments.onDidClose(({ document }) => {\n Effect.runFork(\n Effect.promise(() => connection.sendDiagnostics({ uri: document.uri, diagnostics: [] })),\n )\n})\n\nconnection.onCompletion(({ textDocument: { uri }, position }): CompletionItem[] =>\n withDocument(\n uri,\n (document) =>\n completionItems(currentIndex(), document.getText(), position, currentParseOptions()),\n [],\n ),\n)\n\nconnection.onDefinition(({ textDocument: { uri }, position }): Definition | null =>\n withDocument(\n uri,\n (document) =>\n Option.getOrNull(\n definitionOf(currentIndex(), document.getText(), position, currentParseOptions()),\n ),\n null,\n ),\n)\n\nconnection.languages.semanticTokens.on(({ textDocument: { uri } }): SemanticTokens =>\n // Asked before the document was synced: nothing to colour yet.\n withDocument(uri, semanticTokensOf, { data: [] }),\n)\n\ndocuments.listen(connection)\nconnection.listen()\n"],"mappings":"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"}
|