@telorun/ide-support 0.18.3 → 0.20.0
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/dist/diagnostics/normalize.d.ts.map +1 -1
- package/dist/diagnostics/normalize.js +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/types.d.ts +6 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/workspace/index.d.ts +3 -0
- package/dist/workspace/index.d.ts.map +1 -0
- package/dist/workspace/index.js +1 -0
- package/dist/workspace/workspace-marker.d.ts +59 -0
- package/dist/workspace/workspace-marker.d.ts.map +1 -0
- package/dist/workspace/workspace-marker.js +327 -0
- package/package.json +5 -3
- package/src/diagnostics/normalize.ts +4 -0
- package/src/index.ts +1 -0
- package/src/types.ts +6 -1
- package/src/workspace/index.ts +2 -0
- package/src/workspace/workspace-marker.ts +453 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../../src/diagnostics/normalize.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,KAAK,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAI3E;;;;;;;;;;iDAUiD;AACjD,wBAAgB,mBAAmB,CACjC,CAAC,EAAE,kBAAkB,EACrB,GAAG,EAAE,iBAAiB,GACrB,oBAAoB,
|
|
1
|
+
{"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../../src/diagnostics/normalize.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,KAAK,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAI3E;;;;;;;;;;iDAUiD;AACjD,wBAAgB,mBAAmB,CACjC,CAAC,EAAE,kBAAkB,EACrB,GAAG,EAAE,iBAAiB,GACrB,oBAAoB,CAiBtB"}
|
|
@@ -22,6 +22,10 @@ export function normalizeDiagnostic(d, ctx) {
|
|
|
22
22
|
source: d.source ?? "telo",
|
|
23
23
|
message: d.message,
|
|
24
24
|
...(suggestions ? { suggestions } : {}),
|
|
25
|
+
// Carried through, never derived from the code: which diagnostics are
|
|
26
|
+
// deprecations is the analyzer's to say, and a code list here would be a
|
|
27
|
+
// second place to remember every time one is added.
|
|
28
|
+
...(d.tags?.length ? { tags: [...d.tags] } : {}),
|
|
25
29
|
...(d.data !== undefined ? { data: d.data } : {}),
|
|
26
30
|
};
|
|
27
31
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,5 +6,6 @@ export * from "./semantic-tokens/index.js";
|
|
|
6
6
|
export * from "./definition/index.js";
|
|
7
7
|
export * from "./rename/index.js";
|
|
8
8
|
export * from "./import-upgrades/index.js";
|
|
9
|
+
export * from "./workspace/index.js";
|
|
9
10
|
export { walkCel, flattenChain, chainAt, type ChainPart } from "./cel-chain.js";
|
|
10
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,mBAAmB,CAAC;AAClC,cAAc,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,mBAAmB,CAAC;AAClC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AAIrC,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./semantic-tokens/index.js";
|
|
|
6
6
|
export * from "./definition/index.js";
|
|
7
7
|
export * from "./rename/index.js";
|
|
8
8
|
export * from "./import-upgrades/index.js";
|
|
9
|
+
export * from "./workspace/index.js";
|
|
9
10
|
// The repo's single CEL-tree walk. Exported because every host that has to
|
|
10
11
|
// answer "where is this name read" needs it and a second copy would be a second
|
|
11
12
|
// answer — the editor asks it before deleting a resource.
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { AnalysisRegistry, DiagnosticSeverity } from "@telorun/analyzer";
|
|
1
|
+
export { AnalysisRegistry, DiagnosticSeverity, DiagnosticTag } from "@telorun/analyzer";
|
|
2
2
|
export type { Position, Range, AnalysisDiagnostic, PositionIndex, } from "@telorun/analyzer";
|
|
3
|
-
import type { AnalysisRegistry, DiagnosticSeverity, Position, PositionIndex, Range } from "@telorun/analyzer";
|
|
3
|
+
import type { AnalysisRegistry, DiagnosticSeverity, DiagnosticTag, Position, PositionIndex, Range } from "@telorun/analyzer";
|
|
4
4
|
export type CompletionKind = "class" | "enumMember" | "property" | "folder" | "module" | "value";
|
|
5
5
|
/** A source span the host replaces wholesale when a completion is accepted. */
|
|
6
6
|
export interface ReplaceRange {
|
|
@@ -123,6 +123,10 @@ export interface NormalizedDiagnostic {
|
|
|
123
123
|
kind: "replace";
|
|
124
124
|
replacement: string;
|
|
125
125
|
}>;
|
|
126
|
+
/** LSP diagnostic tags, carried through verbatim. Orthogonal to `severity`:
|
|
127
|
+
* they say what KIND of thing the range is (deprecated, unnecessary), which
|
|
128
|
+
* is what a host renders as strikethrough or fading rather than as a colour. */
|
|
129
|
+
tags?: DiagnosticTag[];
|
|
126
130
|
/** Preserved verbatim from the source `AnalysisDiagnostic`. Carries
|
|
127
131
|
* resource/path stamps that downstream UIs (popovers, "at <path>" hints,
|
|
128
132
|
* CodeAction wiring) read after normalization. Opaque on purpose so this
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Runtime values (classes, enums) — consumers who need `new AnalysisRegistry()`
|
|
2
2
|
// or `DiagnosticSeverity.Error` import from here.
|
|
3
|
-
export { AnalysisRegistry, DiagnosticSeverity } from "@telorun/analyzer";
|
|
3
|
+
export { AnalysisRegistry, DiagnosticSeverity, DiagnosticTag } from "@telorun/analyzer";
|
|
4
4
|
/** The legend a host registers before mapping `buildSemanticTokens` output. The
|
|
5
5
|
* numeric token-type of each `SemanticToken` is its index in this array, and a
|
|
6
6
|
* host registers it once at activation — so new types are APPENDED, never
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/workspace/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AACnF,YAAY,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { workspaceCompletions, workspaceDiagnostics } from "./workspace-marker.js";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editing `telo-workspace.yaml`.
|
|
3
|
+
*
|
|
4
|
+
* The marker is not a manifest — it declares no `kind:`, which is exactly how a
|
|
5
|
+
* host decides a YAML file is Telo's — so nothing here goes through the analysis
|
|
6
|
+
* registry. What it shares with every other surface is the RULE: the shape is
|
|
7
|
+
* declared once as data in the analyzer, the reader is lenient, and this is one
|
|
8
|
+
* of its two consumers. An editor offering a key the checker rejects is the
|
|
9
|
+
* failure a second key list produces, so there is not one.
|
|
10
|
+
*
|
|
11
|
+
* **Two tiers of diagnostic, and the second is optional.** Everything decidable
|
|
12
|
+
* from the text alone always runs. The three that need to see the repo — a
|
|
13
|
+
* pattern matching nothing, an entry a later one shadows, a marker nested under
|
|
14
|
+
* another — need a directory listing, which the browser-safe half must not do,
|
|
15
|
+
* so they arrive through a supplied environment and are simply absent without
|
|
16
|
+
* one (the `buildImportUpgrades` precedent).
|
|
17
|
+
*/
|
|
18
|
+
import { type PatternMatch, type Position } from "@telorun/analyzer";
|
|
19
|
+
import type { CompletionResult, NormalizedDiagnostic } from "../types.js";
|
|
20
|
+
/**
|
|
21
|
+
* What a host can tell this module about the repo the marker sits in.
|
|
22
|
+
*
|
|
23
|
+
* Every member is optional in effect: a host that cannot answer one loses the
|
|
24
|
+
* checks and completions that rest on it, and nothing else.
|
|
25
|
+
*/
|
|
26
|
+
export interface WorkspaceEnvironment {
|
|
27
|
+
/** Gitignore-style matching, from the glob package the host already has. */
|
|
28
|
+
readonly match: PatternMatch;
|
|
29
|
+
/** Workspace-relative POSIX directories, for `env.roots`. */
|
|
30
|
+
readonly directories?: () => readonly string[];
|
|
31
|
+
/** Workspace-relative POSIX directories holding a `telo.yaml`, for
|
|
32
|
+
* `release.modules`. */
|
|
33
|
+
readonly moduleDirectories?: () => readonly string[];
|
|
34
|
+
/** Markers above this one, workspace-relative, nearest first. */
|
|
35
|
+
readonly enclosingMarkers?: () => readonly string[];
|
|
36
|
+
/** Registry bases `.changes/ledger.yaml` records, most common first. */
|
|
37
|
+
readonly recordedRegistries?: () => readonly string[];
|
|
38
|
+
}
|
|
39
|
+
export declare function workspaceDiagnostics(text: string, env?: WorkspaceEnvironment): NormalizedDiagnostic[];
|
|
40
|
+
/**
|
|
41
|
+
* What may be written at the cursor.
|
|
42
|
+
*
|
|
43
|
+
* Keys come from the same declared shape the strict half walks. Values come from
|
|
44
|
+
* the repo where a host can see it: directories holding a manifest at
|
|
45
|
+
* `release.modules`, any directory at `env.roots`, and the base the ledger
|
|
46
|
+
* already records at `release.registry` — that value is written down, and
|
|
47
|
+
* retyping it differently is exactly what `LEDGER_REGISTRY_MISMATCH` exists to
|
|
48
|
+
* catch.
|
|
49
|
+
*
|
|
50
|
+
* **The cursor is located on the SAME parsed document the diagnostics use.** A
|
|
51
|
+
* line/indent scanner beside an AST is two structural readings of one file, and
|
|
52
|
+
* the cheaper one cannot see a flow mapping, a block scalar or a comment
|
|
53
|
+
* containing `key:` — so `release: {modules: [x]}` would silently offer nothing.
|
|
54
|
+
* Indentation is consulted only where the parser genuinely has no node to offer:
|
|
55
|
+
* a half-typed line is not yet part of any collection, which is exactly when
|
|
56
|
+
* completion is asked.
|
|
57
|
+
*/
|
|
58
|
+
export declare function workspaceCompletions(text: string, position: Position, env?: WorkspaceEnvironment): CompletionResult[];
|
|
59
|
+
//# sourceMappingURL=workspace-marker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-marker.d.ts","sourceRoot":"","sources":["../../src/workspace/workspace-marker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAML,KAAK,YAAY,EACjB,KAAK,QAAQ,EAGd,MAAM,mBAAmB,CAAC;AAE3B,OAAO,KAAK,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAE1E;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACnC,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,6DAA6D;IAC7D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,SAAS,MAAM,EAAE,CAAC;IAC/C;6BACyB;IACzB,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,SAAS,MAAM,EAAE,CAAC;IACrD,iEAAiE;IACjE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,SAAS,MAAM,EAAE,CAAC;IACpD,wEAAwE;IACxE,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,SAAS,MAAM,EAAE,CAAC;CACvD;AAID,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EACZ,GAAG,CAAC,EAAE,oBAAoB,GACzB,oBAAoB,EAAE,CAsGxB;AA2FD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,QAAQ,EAClB,GAAG,CAAC,EAAE,oBAAoB,GACzB,gBAAgB,EAAE,CAgDpB"}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editing `telo-workspace.yaml`.
|
|
3
|
+
*
|
|
4
|
+
* The marker is not a manifest — it declares no `kind:`, which is exactly how a
|
|
5
|
+
* host decides a YAML file is Telo's — so nothing here goes through the analysis
|
|
6
|
+
* registry. What it shares with every other surface is the RULE: the shape is
|
|
7
|
+
* declared once as data in the analyzer, the reader is lenient, and this is one
|
|
8
|
+
* of its two consumers. An editor offering a key the checker rejects is the
|
|
9
|
+
* failure a second key list produces, so there is not one.
|
|
10
|
+
*
|
|
11
|
+
* **Two tiers of diagnostic, and the second is optional.** Everything decidable
|
|
12
|
+
* from the text alone always runs. The three that need to see the repo — a
|
|
13
|
+
* pattern matching nothing, an entry a later one shadows, a marker nested under
|
|
14
|
+
* another — need a directory listing, which the browser-safe half must not do,
|
|
15
|
+
* so they arrive through a supplied environment and are simply absent without
|
|
16
|
+
* one (the `buildImportUpgrades` precedent).
|
|
17
|
+
*/
|
|
18
|
+
import { DiagnosticSeverity, WORKSPACE_SCHEMA, MODULE_ENTRY_KEYS, readWorkspaceConfig, settingsForModule, } from "@telorun/analyzer";
|
|
19
|
+
import { isMap, isSeq, parseDocument } from "yaml";
|
|
20
|
+
const SOURCE = "telo-workspace";
|
|
21
|
+
export function workspaceDiagnostics(text, env) {
|
|
22
|
+
const doc = parseDocument(text);
|
|
23
|
+
const { config, diagnostics } = readWorkspaceConfig(text, "telo-workspace.yaml");
|
|
24
|
+
const out = diagnostics.map((diagnostic) => normalize(diagnostic, doc, text));
|
|
25
|
+
if (!env)
|
|
26
|
+
return out;
|
|
27
|
+
const modules = config.release?.modules ?? [];
|
|
28
|
+
const moduleDirs = env.moduleDirectories?.();
|
|
29
|
+
if (moduleDirs) {
|
|
30
|
+
// A pattern under which no directory holds a manifest is overwhelmingly a
|
|
31
|
+
// typo in a subtree name — the failure a four-way workspace split hid for
|
|
32
|
+
// months, because nothing ever said the entry was doing nothing.
|
|
33
|
+
const claimed = modules.map(() => new Set());
|
|
34
|
+
for (const dir of moduleDirs) {
|
|
35
|
+
const settings = settingsForModule({ modules }, dir, env.match);
|
|
36
|
+
if (settings)
|
|
37
|
+
claimed[settings.entry].add(dir);
|
|
38
|
+
}
|
|
39
|
+
for (const [index, entry] of modules.entries()) {
|
|
40
|
+
if (entry.path.startsWith("!"))
|
|
41
|
+
continue;
|
|
42
|
+
const at = ["release", "modules", index];
|
|
43
|
+
if (claimed[index].size !== 0)
|
|
44
|
+
continue;
|
|
45
|
+
// Three ways an entry can claim nothing, and they send an author to three
|
|
46
|
+
// different places: a pattern that matches no directory at all is a typo,
|
|
47
|
+
// while one every later entry re-claims or every later negation removes is
|
|
48
|
+
// correct and inert. Reporting the last two as "matches no directory" is
|
|
49
|
+
// factually wrong and starts a hunt for a typo that is not there.
|
|
50
|
+
const overtaken = modules.findIndex((later, laterIndex) => laterIndex > index &&
|
|
51
|
+
moduleDirs.some((dir) => env.match(dir, [entry.path]) >= 0 && env.match(dir, [later.path]) >= 0));
|
|
52
|
+
if (overtaken < 0) {
|
|
53
|
+
out.push(make("WORKSPACE_ENTRY_MATCHES_NOTHING", `'${entry.path}' matches no directory holding a telo.yaml, so it discovers no module.`, at, doc, text, DiagnosticSeverity.Warning));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const later = modules[overtaken];
|
|
57
|
+
out.push(make("WORKSPACE_ENTRY_SHADOWED", later.path.startsWith("!")
|
|
58
|
+
? `'${entry.path}' claims no module: every one it matches is excluded by ` +
|
|
59
|
+
`'${later.path}', which comes later and therefore wins.`
|
|
60
|
+
: `'${entry.path}' claims no module: every one it matches is also matched by ` +
|
|
61
|
+
`'${later.path}', which comes later and therefore wins. Its settings apply to ` +
|
|
62
|
+
`nothing.`, at, doc, text, DiagnosticSeverity.Warning));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const dirs = env.directories?.();
|
|
66
|
+
if (dirs && config.env?.roots) {
|
|
67
|
+
for (const [index, pattern] of config.env.roots.entries()) {
|
|
68
|
+
if (pattern.startsWith("!"))
|
|
69
|
+
continue;
|
|
70
|
+
if (dirs.some((dir) => env.match(dir, [pattern]) >= 0))
|
|
71
|
+
continue;
|
|
72
|
+
out.push(make("WORKSPACE_ENTRY_MATCHES_NOTHING", `'${pattern}' matches no directory, so it bounds no env walk.`, ["env", "roots", index], doc, text, DiagnosticSeverity.Warning));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const outer = env.enclosingMarkers?.();
|
|
76
|
+
if (outer?.length) {
|
|
77
|
+
out.push(make("WORKSPACE_MARKER_SHADOWED", `Another telo-workspace.yaml sits above this one at '${outer[0]}'. Everything beneath ` +
|
|
78
|
+
`this marker takes a different module cache, different module keys and a different ` +
|
|
79
|
+
`release scope from everything beneath that one.`, [], doc, text, DiagnosticSeverity.Warning));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function normalize(diagnostic, doc, text) {
|
|
84
|
+
return make(diagnostic.code, diagnostic.message, [...diagnostic.path], doc, text, diagnostic.severity === "warning" ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error);
|
|
85
|
+
}
|
|
86
|
+
function make(code, message, path, doc, text, severity) {
|
|
87
|
+
return { range: rangeOf(doc, text, path), severity, code, source: SOURCE, message };
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Ranges
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
/**
|
|
93
|
+
* The span a key path names.
|
|
94
|
+
*
|
|
95
|
+
* A path ending at a mapping key anchors on the KEY, not its value: an unknown
|
|
96
|
+
* key has no value worth underlining, and a wrong-typed one reads better with
|
|
97
|
+
* the name in the squiggle. A path that resolves to nothing falls back to the
|
|
98
|
+
* document's first line rather than to nothing at all, since a diagnostic with
|
|
99
|
+
* no range is one a host silently drops.
|
|
100
|
+
*/
|
|
101
|
+
function rangeOf(doc, text, path) {
|
|
102
|
+
const span = offsetsOf(doc, path);
|
|
103
|
+
return span
|
|
104
|
+
? { start: positionAt(text, span[0]), end: positionAt(text, span[1]) }
|
|
105
|
+
: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
|
|
106
|
+
}
|
|
107
|
+
function offsetsOf(doc, path) {
|
|
108
|
+
if (path.length === 0) {
|
|
109
|
+
const contents = doc.contents;
|
|
110
|
+
return contents?.range ? [contents.range[0], contents.range[0]] : undefined;
|
|
111
|
+
}
|
|
112
|
+
const parent = path.length === 1 ? doc.contents : doc.getIn(path.slice(0, -1), true);
|
|
113
|
+
const last = path[path.length - 1];
|
|
114
|
+
if (isMap(parent) && typeof last === "string") {
|
|
115
|
+
const pair = parent.items.find((item) => String(item.key?.value) === last);
|
|
116
|
+
const node = (pair?.key ?? pair?.value);
|
|
117
|
+
if (node?.range)
|
|
118
|
+
return [node.range[0], node.range[1]];
|
|
119
|
+
}
|
|
120
|
+
if (isSeq(parent) && typeof last === "number") {
|
|
121
|
+
const node = parent.items[last];
|
|
122
|
+
if (node?.range)
|
|
123
|
+
return [node.range[0], node.range[1]];
|
|
124
|
+
}
|
|
125
|
+
const node = doc.getIn(path, true);
|
|
126
|
+
return node?.range ? [node.range[0], node.range[1]] : undefined;
|
|
127
|
+
}
|
|
128
|
+
/** Offsets come from the YAML AST; a host wants line/character. */
|
|
129
|
+
function positionAt(text, offset) {
|
|
130
|
+
const bounded = Math.max(0, Math.min(offset, text.length));
|
|
131
|
+
let line = 0;
|
|
132
|
+
let lineStart = 0;
|
|
133
|
+
for (let i = 0; i < bounded; i++) {
|
|
134
|
+
if (text[i] === "\n") {
|
|
135
|
+
line++;
|
|
136
|
+
lineStart = i + 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { line, character: bounded - lineStart };
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Completion
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
/**
|
|
145
|
+
* What may be written at the cursor.
|
|
146
|
+
*
|
|
147
|
+
* Keys come from the same declared shape the strict half walks. Values come from
|
|
148
|
+
* the repo where a host can see it: directories holding a manifest at
|
|
149
|
+
* `release.modules`, any directory at `env.roots`, and the base the ledger
|
|
150
|
+
* already records at `release.registry` — that value is written down, and
|
|
151
|
+
* retyping it differently is exactly what `LEDGER_REGISTRY_MISMATCH` exists to
|
|
152
|
+
* catch.
|
|
153
|
+
*
|
|
154
|
+
* **The cursor is located on the SAME parsed document the diagnostics use.** A
|
|
155
|
+
* line/indent scanner beside an AST is two structural readings of one file, and
|
|
156
|
+
* the cheaper one cannot see a flow mapping, a block scalar or a comment
|
|
157
|
+
* containing `key:` — so `release: {modules: [x]}` would silently offer nothing.
|
|
158
|
+
* Indentation is consulted only where the parser genuinely has no node to offer:
|
|
159
|
+
* a half-typed line is not yet part of any collection, which is exactly when
|
|
160
|
+
* completion is asked.
|
|
161
|
+
*/
|
|
162
|
+
export function workspaceCompletions(text, position, env) {
|
|
163
|
+
const doc = parseDocument(text);
|
|
164
|
+
const at = contextAt(doc, text, position);
|
|
165
|
+
if (at.kind === "value") {
|
|
166
|
+
switch (at.path.join(".")) {
|
|
167
|
+
case "release.registry":
|
|
168
|
+
return (env?.recordedRegistries?.() ?? []).map((base) => ({
|
|
169
|
+
label: base,
|
|
170
|
+
kind: "value",
|
|
171
|
+
detail: "recorded in .changes/ledger.yaml",
|
|
172
|
+
}));
|
|
173
|
+
case "release.modules":
|
|
174
|
+
return (env?.moduleDirectories?.() ?? []).map((dir) => ({
|
|
175
|
+
label: dir,
|
|
176
|
+
kind: "folder",
|
|
177
|
+
detail: "holds a telo.yaml",
|
|
178
|
+
}));
|
|
179
|
+
case "release.ignore":
|
|
180
|
+
return valuesFrom(WORKSPACE_SCHEMA.release.properties.ignore.examples);
|
|
181
|
+
case "env.roots":
|
|
182
|
+
return (env?.directories?.() ?? []).map((dir) => ({ label: dir, kind: "folder" }));
|
|
183
|
+
case "env.files":
|
|
184
|
+
return valuesFrom(WORKSPACE_SCHEMA.env.properties.files.examples);
|
|
185
|
+
default:
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// A key position. Which key set depends only on where the path lands.
|
|
190
|
+
if (at.path.length === 0) {
|
|
191
|
+
return Object.entries(WORKSPACE_SCHEMA).map(([name, schema]) => keyItem(name, schema.description));
|
|
192
|
+
}
|
|
193
|
+
const [block, ...rest] = at.path;
|
|
194
|
+
if (block === "release") {
|
|
195
|
+
// Inside one `modules:` entry the keys are the entry's, not the block's.
|
|
196
|
+
const properties = rest[0] === "modules" && rest.length > 1 ? MODULE_ENTRY_KEYS : WORKSPACE_SCHEMA.release.properties;
|
|
197
|
+
return Object.entries(properties).map(([name, schema]) => keyItem(name, schema.description));
|
|
198
|
+
}
|
|
199
|
+
if (block === "env") {
|
|
200
|
+
return Object.entries(WORKSPACE_SCHEMA.env.properties).map(([name, schema]) => keyItem(name, schema.description));
|
|
201
|
+
}
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
function valuesFrom(examples) {
|
|
205
|
+
return (examples ?? []).map((label) => ({ label, kind: "value" }));
|
|
206
|
+
}
|
|
207
|
+
function keyItem(name, documentation) {
|
|
208
|
+
return { label: name, kind: "property", insertText: `${name}:`, documentation };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Where the cursor is, off the parsed document.
|
|
212
|
+
*
|
|
213
|
+
* The AST answers whenever the document contains a node covering the offset,
|
|
214
|
+
* which is every complete construct including flow mappings. The indentation
|
|
215
|
+
* fallback covers exactly one case the parser cannot: a line the author has
|
|
216
|
+
* started but not finished, which belongs to no node yet.
|
|
217
|
+
*/
|
|
218
|
+
function contextAt(doc, text, position) {
|
|
219
|
+
const offset = offsetOf(text, position);
|
|
220
|
+
const line = text.split("\n")[position.line] ?? "";
|
|
221
|
+
// A `key:` with nothing after it on the line, or a sequence item — both are
|
|
222
|
+
// value positions, and both are decidable from the line the cursor is on
|
|
223
|
+
// without guessing at STRUCTURE, which is what the AST supplies below.
|
|
224
|
+
const afterKey = /^\s*([A-Za-z][A-Za-z0-9_]*):\s*\S*$/.exec(line);
|
|
225
|
+
const inItem = /^\s*-\s*\S*$/.test(line);
|
|
226
|
+
// The AST decides both facts where it has a node: which collection the offset
|
|
227
|
+
// is in, and whether that position holds a key or a value. Only where it has
|
|
228
|
+
// none — a half-typed line — does the line itself answer.
|
|
229
|
+
const fromAst = pathAtOffset(doc, offset);
|
|
230
|
+
if (fromAst)
|
|
231
|
+
return fromAst;
|
|
232
|
+
const path = indentPath(text, position.line);
|
|
233
|
+
if (afterKey)
|
|
234
|
+
return { kind: "value", path: [...path, afterKey[1]] };
|
|
235
|
+
if (inItem)
|
|
236
|
+
return { kind: "value", path };
|
|
237
|
+
return { kind: "key", path };
|
|
238
|
+
}
|
|
239
|
+
function offsetOf(text, position) {
|
|
240
|
+
const lines = text.split("\n");
|
|
241
|
+
let offset = 0;
|
|
242
|
+
for (let i = 0; i < position.line && i < lines.length; i++)
|
|
243
|
+
offset += lines[i].length + 1;
|
|
244
|
+
return offset + position.character;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Where `offset` lands, or `undefined` when no node covers it — a half-typed
|
|
248
|
+
* line belongs to nothing yet.
|
|
249
|
+
*
|
|
250
|
+
* Landing on a mapping means a key is being written; landing inside a pair's
|
|
251
|
+
* value or a sequence item means a value is. That is the same distinction a line
|
|
252
|
+
* scanner guesses at from a trailing `:` or a `- `, decided here from what the
|
|
253
|
+
* parser actually built — which is why a flow mapping resolves at all.
|
|
254
|
+
*/
|
|
255
|
+
function pathAtOffset(doc, offset) {
|
|
256
|
+
const path = [];
|
|
257
|
+
let node = doc.contents;
|
|
258
|
+
let covered = false;
|
|
259
|
+
let kind = "key";
|
|
260
|
+
for (;;) {
|
|
261
|
+
if (isMap(node)) {
|
|
262
|
+
const pair = node.items.find((item) => within(item.value, offset) || within(item.key, offset));
|
|
263
|
+
if (!pair)
|
|
264
|
+
break;
|
|
265
|
+
covered = true;
|
|
266
|
+
// On the key itself, the author is writing a key of THIS mapping.
|
|
267
|
+
if (within(pair.key, offset) && !within(pair.value, offset)) {
|
|
268
|
+
kind = "key";
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const key = pair.key?.value;
|
|
272
|
+
if (typeof key === "string")
|
|
273
|
+
path.push(key);
|
|
274
|
+
kind = "value";
|
|
275
|
+
node = pair.value;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (isSeq(node)) {
|
|
279
|
+
const item = node.items.find((candidate) => within(candidate, offset));
|
|
280
|
+
if (!item)
|
|
281
|
+
break;
|
|
282
|
+
covered = true;
|
|
283
|
+
kind = "value";
|
|
284
|
+
node = item;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
if (!covered)
|
|
290
|
+
return undefined;
|
|
291
|
+
// A mapping the walk ended ON is a key position, whatever reached it.
|
|
292
|
+
if (isMap(node))
|
|
293
|
+
kind = "key";
|
|
294
|
+
return { kind, path };
|
|
295
|
+
}
|
|
296
|
+
function within(node, offset) {
|
|
297
|
+
const range = node?.range;
|
|
298
|
+
return range !== undefined && offset >= range[0] && offset <= range[1];
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* The keys enclosing a line the parser has no node for, by indentation.
|
|
302
|
+
*
|
|
303
|
+
* The one fallback, and it is not a second reading of the document: it runs only
|
|
304
|
+
* where the AST has nothing to say, and it answers the same question in the same
|
|
305
|
+
* vocabulary.
|
|
306
|
+
*/
|
|
307
|
+
function indentPath(text, at) {
|
|
308
|
+
const lines = text.split("\n");
|
|
309
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
310
|
+
let want = indentOf(lines[at] ?? "");
|
|
311
|
+
const path = [];
|
|
312
|
+
for (let i = at - 1; i >= 0; i--) {
|
|
313
|
+
const line = lines[i] ?? "";
|
|
314
|
+
if (line.trim() === "" || line.trimStart().startsWith("#"))
|
|
315
|
+
continue;
|
|
316
|
+
const indent = indentOf(line);
|
|
317
|
+
if (indent >= want)
|
|
318
|
+
continue;
|
|
319
|
+
const key = /^\s*-?\s*([A-Za-z][A-Za-z0-9_]*):/.exec(line);
|
|
320
|
+
if (key)
|
|
321
|
+
path.unshift(key[1]);
|
|
322
|
+
want = indent;
|
|
323
|
+
if (indent === 0)
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
return path;
|
|
327
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/ide-support",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Editor-host-agnostic IDE support (completions, diagnostic normalization) for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -36,12 +36,14 @@
|
|
|
36
36
|
"src/**"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"
|
|
39
|
+
"yaml": "^2.8.3",
|
|
40
|
+
"@telorun/analyzer": "0.73.0"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"@types/node": "^20.0.0",
|
|
43
44
|
"typescript": "^5.0.0",
|
|
44
|
-
"vitest": "^2.1.8"
|
|
45
|
+
"vitest": "^2.1.8",
|
|
46
|
+
"@telorun/glob": "0.3.0"
|
|
45
47
|
},
|
|
46
48
|
"scripts": {
|
|
47
49
|
"build": "tsc -p tsconfig.lib.json",
|
|
@@ -28,6 +28,10 @@ export function normalizeDiagnostic(
|
|
|
28
28
|
source: d.source ?? "telo",
|
|
29
29
|
message: d.message,
|
|
30
30
|
...(suggestions ? { suggestions } : {}),
|
|
31
|
+
// Carried through, never derived from the code: which diagnostics are
|
|
32
|
+
// deprecations is the analyzer's to say, and a code list here would be a
|
|
33
|
+
// second place to remember every time one is added.
|
|
34
|
+
...(d.tags?.length ? { tags: [...d.tags] } : {}),
|
|
31
35
|
...(d.data !== undefined ? { data: d.data } : {}),
|
|
32
36
|
};
|
|
33
37
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./semantic-tokens/index.js";
|
|
|
6
6
|
export * from "./definition/index.js";
|
|
7
7
|
export * from "./rename/index.js";
|
|
8
8
|
export * from "./import-upgrades/index.js";
|
|
9
|
+
export * from "./workspace/index.js";
|
|
9
10
|
// The repo's single CEL-tree walk. Exported because every host that has to
|
|
10
11
|
// answer "where is this name read" needs it and a second copy would be a second
|
|
11
12
|
// answer — the editor asks it before deleting a resource.
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Runtime values (classes, enums) — consumers who need `new AnalysisRegistry()`
|
|
2
2
|
// or `DiagnosticSeverity.Error` import from here.
|
|
3
|
-
export { AnalysisRegistry, DiagnosticSeverity } from "@telorun/analyzer";
|
|
3
|
+
export { AnalysisRegistry, DiagnosticSeverity, DiagnosticTag } from "@telorun/analyzer";
|
|
4
4
|
|
|
5
5
|
// Pure types.
|
|
6
6
|
export type {
|
|
@@ -13,6 +13,7 @@ export type {
|
|
|
13
13
|
import type {
|
|
14
14
|
AnalysisRegistry,
|
|
15
15
|
DiagnosticSeverity,
|
|
16
|
+
DiagnosticTag,
|
|
16
17
|
Position,
|
|
17
18
|
PositionIndex,
|
|
18
19
|
Range,
|
|
@@ -168,6 +169,10 @@ export interface NormalizedDiagnostic {
|
|
|
168
169
|
/** Mechanically applicable repairs. `replacement` is the whole corrected
|
|
169
170
|
* value at the diagnostic's range — apply it by replacing that range. */
|
|
170
171
|
suggestions?: Array<{ kind: "replace"; replacement: string }>;
|
|
172
|
+
/** LSP diagnostic tags, carried through verbatim. Orthogonal to `severity`:
|
|
173
|
+
* they say what KIND of thing the range is (deprecated, unnecessary), which
|
|
174
|
+
* is what a host renders as strikethrough or fading rather than as a colour. */
|
|
175
|
+
tags?: DiagnosticTag[];
|
|
171
176
|
/** Preserved verbatim from the source `AnalysisDiagnostic`. Carries
|
|
172
177
|
* resource/path stamps that downstream UIs (popovers, "at <path>" hints,
|
|
173
178
|
* CodeAction wiring) read after normalization. Opaque on purpose so this
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editing `telo-workspace.yaml`.
|
|
3
|
+
*
|
|
4
|
+
* The marker is not a manifest — it declares no `kind:`, which is exactly how a
|
|
5
|
+
* host decides a YAML file is Telo's — so nothing here goes through the analysis
|
|
6
|
+
* registry. What it shares with every other surface is the RULE: the shape is
|
|
7
|
+
* declared once as data in the analyzer, the reader is lenient, and this is one
|
|
8
|
+
* of its two consumers. An editor offering a key the checker rejects is the
|
|
9
|
+
* failure a second key list produces, so there is not one.
|
|
10
|
+
*
|
|
11
|
+
* **Two tiers of diagnostic, and the second is optional.** Everything decidable
|
|
12
|
+
* from the text alone always runs. The three that need to see the repo — a
|
|
13
|
+
* pattern matching nothing, an entry a later one shadows, a marker nested under
|
|
14
|
+
* another — need a directory listing, which the browser-safe half must not do,
|
|
15
|
+
* so they arrive through a supplied environment and are simply absent without
|
|
16
|
+
* one (the `buildImportUpgrades` precedent).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
DiagnosticSeverity,
|
|
21
|
+
WORKSPACE_SCHEMA,
|
|
22
|
+
MODULE_ENTRY_KEYS,
|
|
23
|
+
readWorkspaceConfig,
|
|
24
|
+
settingsForModule,
|
|
25
|
+
type PatternMatch,
|
|
26
|
+
type Position,
|
|
27
|
+
type Range,
|
|
28
|
+
type WorkspaceDiagnostic,
|
|
29
|
+
} from "@telorun/analyzer";
|
|
30
|
+
import { isMap, isSeq, parseDocument, type Document, type Node } from "yaml";
|
|
31
|
+
import type { CompletionResult, NormalizedDiagnostic } from "../types.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a host can tell this module about the repo the marker sits in.
|
|
35
|
+
*
|
|
36
|
+
* Every member is optional in effect: a host that cannot answer one loses the
|
|
37
|
+
* checks and completions that rest on it, and nothing else.
|
|
38
|
+
*/
|
|
39
|
+
export interface WorkspaceEnvironment {
|
|
40
|
+
/** Gitignore-style matching, from the glob package the host already has. */
|
|
41
|
+
readonly match: PatternMatch;
|
|
42
|
+
/** Workspace-relative POSIX directories, for `env.roots`. */
|
|
43
|
+
readonly directories?: () => readonly string[];
|
|
44
|
+
/** Workspace-relative POSIX directories holding a `telo.yaml`, for
|
|
45
|
+
* `release.modules`. */
|
|
46
|
+
readonly moduleDirectories?: () => readonly string[];
|
|
47
|
+
/** Markers above this one, workspace-relative, nearest first. */
|
|
48
|
+
readonly enclosingMarkers?: () => readonly string[];
|
|
49
|
+
/** Registry bases `.changes/ledger.yaml` records, most common first. */
|
|
50
|
+
readonly recordedRegistries?: () => readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SOURCE = "telo-workspace";
|
|
54
|
+
|
|
55
|
+
export function workspaceDiagnostics(
|
|
56
|
+
text: string,
|
|
57
|
+
env?: WorkspaceEnvironment,
|
|
58
|
+
): NormalizedDiagnostic[] {
|
|
59
|
+
const doc = parseDocument(text);
|
|
60
|
+
const { config, diagnostics } = readWorkspaceConfig(text, "telo-workspace.yaml");
|
|
61
|
+
const out = diagnostics.map((diagnostic) => normalize(diagnostic, doc, text));
|
|
62
|
+
|
|
63
|
+
if (!env) return out;
|
|
64
|
+
|
|
65
|
+
const modules = config.release?.modules ?? [];
|
|
66
|
+
const moduleDirs = env.moduleDirectories?.();
|
|
67
|
+
if (moduleDirs) {
|
|
68
|
+
// A pattern under which no directory holds a manifest is overwhelmingly a
|
|
69
|
+
// typo in a subtree name — the failure a four-way workspace split hid for
|
|
70
|
+
// months, because nothing ever said the entry was doing nothing.
|
|
71
|
+
const claimed = modules.map(() => new Set<string>());
|
|
72
|
+
for (const dir of moduleDirs) {
|
|
73
|
+
const settings = settingsForModule({ modules }, dir, env.match);
|
|
74
|
+
if (settings) claimed[settings.entry]!.add(dir);
|
|
75
|
+
}
|
|
76
|
+
for (const [index, entry] of modules.entries()) {
|
|
77
|
+
if (entry.path.startsWith("!")) continue;
|
|
78
|
+
const at: (string | number)[] = ["release", "modules", index];
|
|
79
|
+
if (claimed[index]!.size !== 0) continue;
|
|
80
|
+
|
|
81
|
+
// Three ways an entry can claim nothing, and they send an author to three
|
|
82
|
+
// different places: a pattern that matches no directory at all is a typo,
|
|
83
|
+
// while one every later entry re-claims or every later negation removes is
|
|
84
|
+
// correct and inert. Reporting the last two as "matches no directory" is
|
|
85
|
+
// factually wrong and starts a hunt for a typo that is not there.
|
|
86
|
+
const overtaken = modules.findIndex(
|
|
87
|
+
(later, laterIndex) =>
|
|
88
|
+
laterIndex > index &&
|
|
89
|
+
moduleDirs.some(
|
|
90
|
+
(dir) => env.match(dir, [entry.path]) >= 0 && env.match(dir, [later.path]) >= 0,
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
if (overtaken < 0) {
|
|
94
|
+
out.push(
|
|
95
|
+
make(
|
|
96
|
+
"WORKSPACE_ENTRY_MATCHES_NOTHING",
|
|
97
|
+
`'${entry.path}' matches no directory holding a telo.yaml, so it discovers no module.`,
|
|
98
|
+
at,
|
|
99
|
+
doc,
|
|
100
|
+
text,
|
|
101
|
+
DiagnosticSeverity.Warning,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const later = modules[overtaken]!;
|
|
107
|
+
out.push(
|
|
108
|
+
make(
|
|
109
|
+
"WORKSPACE_ENTRY_SHADOWED",
|
|
110
|
+
later.path.startsWith("!")
|
|
111
|
+
? `'${entry.path}' claims no module: every one it matches is excluded by ` +
|
|
112
|
+
`'${later.path}', which comes later and therefore wins.`
|
|
113
|
+
: `'${entry.path}' claims no module: every one it matches is also matched by ` +
|
|
114
|
+
`'${later.path}', which comes later and therefore wins. Its settings apply to ` +
|
|
115
|
+
`nothing.`,
|
|
116
|
+
at,
|
|
117
|
+
doc,
|
|
118
|
+
text,
|
|
119
|
+
DiagnosticSeverity.Warning,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const dirs = env.directories?.();
|
|
126
|
+
if (dirs && config.env?.roots) {
|
|
127
|
+
for (const [index, pattern] of config.env.roots.entries()) {
|
|
128
|
+
if (pattern.startsWith("!")) continue;
|
|
129
|
+
if (dirs.some((dir) => env.match(dir, [pattern]) >= 0)) continue;
|
|
130
|
+
out.push(
|
|
131
|
+
make(
|
|
132
|
+
"WORKSPACE_ENTRY_MATCHES_NOTHING",
|
|
133
|
+
`'${pattern}' matches no directory, so it bounds no env walk.`,
|
|
134
|
+
["env", "roots", index],
|
|
135
|
+
doc,
|
|
136
|
+
text,
|
|
137
|
+
DiagnosticSeverity.Warning,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const outer = env.enclosingMarkers?.();
|
|
144
|
+
if (outer?.length) {
|
|
145
|
+
out.push(
|
|
146
|
+
make(
|
|
147
|
+
"WORKSPACE_MARKER_SHADOWED",
|
|
148
|
+
`Another telo-workspace.yaml sits above this one at '${outer[0]}'. Everything beneath ` +
|
|
149
|
+
`this marker takes a different module cache, different module keys and a different ` +
|
|
150
|
+
`release scope from everything beneath that one.`,
|
|
151
|
+
[],
|
|
152
|
+
doc,
|
|
153
|
+
text,
|
|
154
|
+
DiagnosticSeverity.Warning,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalize(
|
|
163
|
+
diagnostic: WorkspaceDiagnostic,
|
|
164
|
+
doc: Document,
|
|
165
|
+
text: string,
|
|
166
|
+
): NormalizedDiagnostic {
|
|
167
|
+
return make(
|
|
168
|
+
diagnostic.code,
|
|
169
|
+
diagnostic.message,
|
|
170
|
+
[...diagnostic.path],
|
|
171
|
+
doc,
|
|
172
|
+
text,
|
|
173
|
+
diagnostic.severity === "warning" ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function make(
|
|
178
|
+
code: string,
|
|
179
|
+
message: string,
|
|
180
|
+
path: (string | number)[],
|
|
181
|
+
doc: Document,
|
|
182
|
+
text: string,
|
|
183
|
+
severity: DiagnosticSeverity,
|
|
184
|
+
): NormalizedDiagnostic {
|
|
185
|
+
return { range: rangeOf(doc, text, path), severity, code, source: SOURCE, message };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Ranges
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The span a key path names.
|
|
194
|
+
*
|
|
195
|
+
* A path ending at a mapping key anchors on the KEY, not its value: an unknown
|
|
196
|
+
* key has no value worth underlining, and a wrong-typed one reads better with
|
|
197
|
+
* the name in the squiggle. A path that resolves to nothing falls back to the
|
|
198
|
+
* document's first line rather than to nothing at all, since a diagnostic with
|
|
199
|
+
* no range is one a host silently drops.
|
|
200
|
+
*/
|
|
201
|
+
function rangeOf(doc: Document, text: string, path: (string | number)[]): Range {
|
|
202
|
+
const span = offsetsOf(doc, path);
|
|
203
|
+
return span
|
|
204
|
+
? { start: positionAt(text, span[0]), end: positionAt(text, span[1]) }
|
|
205
|
+
: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function offsetsOf(doc: Document, path: (string | number)[]): [number, number] | undefined {
|
|
209
|
+
if (path.length === 0) {
|
|
210
|
+
const contents = doc.contents as { range?: [number, number, number] } | null;
|
|
211
|
+
return contents?.range ? [contents.range[0], contents.range[0]] : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const parent = path.length === 1 ? doc.contents : (doc.getIn(path.slice(0, -1), true) as Node);
|
|
215
|
+
const last = path[path.length - 1]!;
|
|
216
|
+
|
|
217
|
+
if (isMap(parent) && typeof last === "string") {
|
|
218
|
+
const pair = parent.items.find(
|
|
219
|
+
(item) => String((item.key as { value?: unknown })?.value) === last,
|
|
220
|
+
);
|
|
221
|
+
const node = (pair?.key ?? pair?.value) as { range?: [number, number, number] } | undefined;
|
|
222
|
+
if (node?.range) return [node.range[0], node.range[1]];
|
|
223
|
+
}
|
|
224
|
+
if (isSeq(parent) && typeof last === "number") {
|
|
225
|
+
const node = parent.items[last] as { range?: [number, number, number] } | undefined;
|
|
226
|
+
if (node?.range) return [node.range[0], node.range[1]];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const node = doc.getIn(path, true) as { range?: [number, number, number] } | undefined;
|
|
230
|
+
return node?.range ? [node.range[0], node.range[1]] : undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Offsets come from the YAML AST; a host wants line/character. */
|
|
234
|
+
function positionAt(text: string, offset: number): Position {
|
|
235
|
+
const bounded = Math.max(0, Math.min(offset, text.length));
|
|
236
|
+
let line = 0;
|
|
237
|
+
let lineStart = 0;
|
|
238
|
+
for (let i = 0; i < bounded; i++) {
|
|
239
|
+
if (text[i] === "\n") {
|
|
240
|
+
line++;
|
|
241
|
+
lineStart = i + 1;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { line, character: bounded - lineStart };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Completion
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* What may be written at the cursor.
|
|
253
|
+
*
|
|
254
|
+
* Keys come from the same declared shape the strict half walks. Values come from
|
|
255
|
+
* the repo where a host can see it: directories holding a manifest at
|
|
256
|
+
* `release.modules`, any directory at `env.roots`, and the base the ledger
|
|
257
|
+
* already records at `release.registry` — that value is written down, and
|
|
258
|
+
* retyping it differently is exactly what `LEDGER_REGISTRY_MISMATCH` exists to
|
|
259
|
+
* catch.
|
|
260
|
+
*
|
|
261
|
+
* **The cursor is located on the SAME parsed document the diagnostics use.** A
|
|
262
|
+
* line/indent scanner beside an AST is two structural readings of one file, and
|
|
263
|
+
* the cheaper one cannot see a flow mapping, a block scalar or a comment
|
|
264
|
+
* containing `key:` — so `release: {modules: [x]}` would silently offer nothing.
|
|
265
|
+
* Indentation is consulted only where the parser genuinely has no node to offer:
|
|
266
|
+
* a half-typed line is not yet part of any collection, which is exactly when
|
|
267
|
+
* completion is asked.
|
|
268
|
+
*/
|
|
269
|
+
export function workspaceCompletions(
|
|
270
|
+
text: string,
|
|
271
|
+
position: Position,
|
|
272
|
+
env?: WorkspaceEnvironment,
|
|
273
|
+
): CompletionResult[] {
|
|
274
|
+
const doc = parseDocument(text);
|
|
275
|
+
const at = contextAt(doc, text, position);
|
|
276
|
+
|
|
277
|
+
if (at.kind === "value") {
|
|
278
|
+
switch (at.path.join(".")) {
|
|
279
|
+
case "release.registry":
|
|
280
|
+
return (env?.recordedRegistries?.() ?? []).map((base) => ({
|
|
281
|
+
label: base,
|
|
282
|
+
kind: "value" as const,
|
|
283
|
+
detail: "recorded in .changes/ledger.yaml",
|
|
284
|
+
}));
|
|
285
|
+
case "release.modules":
|
|
286
|
+
return (env?.moduleDirectories?.() ?? []).map((dir) => ({
|
|
287
|
+
label: dir,
|
|
288
|
+
kind: "folder" as const,
|
|
289
|
+
detail: "holds a telo.yaml",
|
|
290
|
+
}));
|
|
291
|
+
case "release.ignore":
|
|
292
|
+
return valuesFrom(WORKSPACE_SCHEMA.release.properties.ignore.examples);
|
|
293
|
+
case "env.roots":
|
|
294
|
+
return (env?.directories?.() ?? []).map((dir) => ({ label: dir, kind: "folder" as const }));
|
|
295
|
+
case "env.files":
|
|
296
|
+
return valuesFrom(WORKSPACE_SCHEMA.env.properties.files.examples);
|
|
297
|
+
default:
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// A key position. Which key set depends only on where the path lands.
|
|
303
|
+
if (at.path.length === 0) {
|
|
304
|
+
return Object.entries(WORKSPACE_SCHEMA).map(([name, schema]) =>
|
|
305
|
+
keyItem(name, schema.description),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const [block, ...rest] = at.path;
|
|
309
|
+
if (block === "release") {
|
|
310
|
+
// Inside one `modules:` entry the keys are the entry's, not the block's.
|
|
311
|
+
const properties =
|
|
312
|
+
rest[0] === "modules" && rest.length > 1 ? MODULE_ENTRY_KEYS : WORKSPACE_SCHEMA.release.properties;
|
|
313
|
+
return Object.entries(properties).map(([name, schema]) => keyItem(name, schema.description));
|
|
314
|
+
}
|
|
315
|
+
if (block === "env") {
|
|
316
|
+
return Object.entries(WORKSPACE_SCHEMA.env.properties).map(([name, schema]) =>
|
|
317
|
+
keyItem(name, schema.description),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function valuesFrom(examples: readonly string[] | undefined): CompletionResult[] {
|
|
324
|
+
return (examples ?? []).map((label) => ({ label, kind: "value" as const }));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function keyItem(name: string, documentation: string): CompletionResult {
|
|
328
|
+
return { label: name, kind: "property", insertText: `${name}:`, documentation };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
interface CursorContext {
|
|
332
|
+
/** `value` when the cursor sits where a value goes — after a `key:` or in a
|
|
333
|
+
* sequence item; `key` when it sits where a key goes. */
|
|
334
|
+
readonly kind: "key" | "value";
|
|
335
|
+
/** The path to the collection or key the cursor is in. For a value it names
|
|
336
|
+
* the key whose value is being written. */
|
|
337
|
+
readonly path: readonly string[];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Where the cursor is, off the parsed document.
|
|
342
|
+
*
|
|
343
|
+
* The AST answers whenever the document contains a node covering the offset,
|
|
344
|
+
* which is every complete construct including flow mappings. The indentation
|
|
345
|
+
* fallback covers exactly one case the parser cannot: a line the author has
|
|
346
|
+
* started but not finished, which belongs to no node yet.
|
|
347
|
+
*/
|
|
348
|
+
function contextAt(doc: Document, text: string, position: Position): CursorContext {
|
|
349
|
+
const offset = offsetOf(text, position);
|
|
350
|
+
const line = text.split("\n")[position.line] ?? "";
|
|
351
|
+
|
|
352
|
+
// A `key:` with nothing after it on the line, or a sequence item — both are
|
|
353
|
+
// value positions, and both are decidable from the line the cursor is on
|
|
354
|
+
// without guessing at STRUCTURE, which is what the AST supplies below.
|
|
355
|
+
const afterKey = /^\s*([A-Za-z][A-Za-z0-9_]*):\s*\S*$/.exec(line);
|
|
356
|
+
const inItem = /^\s*-\s*\S*$/.test(line);
|
|
357
|
+
|
|
358
|
+
// The AST decides both facts where it has a node: which collection the offset
|
|
359
|
+
// is in, and whether that position holds a key or a value. Only where it has
|
|
360
|
+
// none — a half-typed line — does the line itself answer.
|
|
361
|
+
const fromAst = pathAtOffset(doc, offset);
|
|
362
|
+
if (fromAst) return fromAst;
|
|
363
|
+
|
|
364
|
+
const path = indentPath(text, position.line);
|
|
365
|
+
if (afterKey) return { kind: "value", path: [...path, afterKey[1]!] };
|
|
366
|
+
if (inItem) return { kind: "value", path };
|
|
367
|
+
return { kind: "key", path };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function offsetOf(text: string, position: Position): number {
|
|
371
|
+
const lines = text.split("\n");
|
|
372
|
+
let offset = 0;
|
|
373
|
+
for (let i = 0; i < position.line && i < lines.length; i++) offset += lines[i]!.length + 1;
|
|
374
|
+
return offset + position.character;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Where `offset` lands, or `undefined` when no node covers it — a half-typed
|
|
379
|
+
* line belongs to nothing yet.
|
|
380
|
+
*
|
|
381
|
+
* Landing on a mapping means a key is being written; landing inside a pair's
|
|
382
|
+
* value or a sequence item means a value is. That is the same distinction a line
|
|
383
|
+
* scanner guesses at from a trailing `:` or a `- `, decided here from what the
|
|
384
|
+
* parser actually built — which is why a flow mapping resolves at all.
|
|
385
|
+
*/
|
|
386
|
+
function pathAtOffset(doc: Document, offset: number): CursorContext | undefined {
|
|
387
|
+
const path: string[] = [];
|
|
388
|
+
let node: unknown = doc.contents;
|
|
389
|
+
let covered = false;
|
|
390
|
+
let kind: "key" | "value" = "key";
|
|
391
|
+
|
|
392
|
+
for (;;) {
|
|
393
|
+
if (isMap(node)) {
|
|
394
|
+
const pair = node.items.find((item) => within(item.value, offset) || within(item.key, offset));
|
|
395
|
+
if (!pair) break;
|
|
396
|
+
covered = true;
|
|
397
|
+
// On the key itself, the author is writing a key of THIS mapping.
|
|
398
|
+
if (within(pair.key, offset) && !within(pair.value, offset)) {
|
|
399
|
+
kind = "key";
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
const key = (pair.key as { value?: unknown } | null)?.value;
|
|
403
|
+
if (typeof key === "string") path.push(key);
|
|
404
|
+
kind = "value";
|
|
405
|
+
node = pair.value;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (isSeq(node)) {
|
|
409
|
+
const item = node.items.find((candidate) => within(candidate, offset));
|
|
410
|
+
if (!item) break;
|
|
411
|
+
covered = true;
|
|
412
|
+
kind = "value";
|
|
413
|
+
node = item;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!covered) return undefined;
|
|
420
|
+
// A mapping the walk ended ON is a key position, whatever reached it.
|
|
421
|
+
if (isMap(node)) kind = "key";
|
|
422
|
+
return { kind, path };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function within(node: unknown, offset: number): boolean {
|
|
426
|
+
const range = (node as { range?: [number, number, number] } | null)?.range;
|
|
427
|
+
return range !== undefined && offset >= range[0] && offset <= range[1];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The keys enclosing a line the parser has no node for, by indentation.
|
|
432
|
+
*
|
|
433
|
+
* The one fallback, and it is not a second reading of the document: it runs only
|
|
434
|
+
* where the AST has nothing to say, and it answers the same question in the same
|
|
435
|
+
* vocabulary.
|
|
436
|
+
*/
|
|
437
|
+
function indentPath(text: string, at: number): string[] {
|
|
438
|
+
const lines = text.split("\n");
|
|
439
|
+
const indentOf = (line: string): number => line.length - line.trimStart().length;
|
|
440
|
+
let want = indentOf(lines[at] ?? "");
|
|
441
|
+
const path: string[] = [];
|
|
442
|
+
for (let i = at - 1; i >= 0; i--) {
|
|
443
|
+
const line = lines[i] ?? "";
|
|
444
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
|
|
445
|
+
const indent = indentOf(line);
|
|
446
|
+
if (indent >= want) continue;
|
|
447
|
+
const key = /^\s*-?\s*([A-Za-z][A-Za-z0-9_]*):/.exec(line);
|
|
448
|
+
if (key) path.unshift(key[1]!);
|
|
449
|
+
want = indent;
|
|
450
|
+
if (indent === 0) break;
|
|
451
|
+
}
|
|
452
|
+
return path;
|
|
453
|
+
}
|