@tsuzuri-lab/sed-language-server 0.2.0 → 0.3.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/README.md CHANGED
@@ -22,6 +22,7 @@ sed-language-server --stdio
22
22
 
23
23
  - Tree-sitter-based diagnostics
24
24
  - go to definition from `b`, `t`, and GNU `T` label references
25
+ - document formatting for command lists and nested blocks
25
26
 
26
27
  Scripts are analyzed without executing them or accessing referenced files.
27
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsuzuri-lab/sed-language-server",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Language server for POSIX sed with opt-in GNU syntax support.",
5
5
  "keywords": [
6
6
  "sed",
@@ -0,0 +1,44 @@
1
+ import { rangeForNode, syntaxTreeFor } from "./syntax.js";
2
+
3
+ function collectLabels(rootNode, offset) {
4
+ const definitions = [];
5
+ let reference;
6
+ const stack = [rootNode];
7
+
8
+ while (stack.length > 0) {
9
+ const node = stack.pop();
10
+ if (node.type === "label_definition") {
11
+ definitions.push(node);
12
+ } else if (
13
+ node.type === "label_reference" &&
14
+ node.startIndex <= offset &&
15
+ offset < node.endIndex
16
+ ) {
17
+ reference = node;
18
+ }
19
+ stack.push(...node.children.toReversed());
20
+ }
21
+
22
+ return { definitions, reference };
23
+ }
24
+
25
+ export function createDefinitionLocations(document, position, dialect) {
26
+ const tree = syntaxTreeFor(document, dialect);
27
+ const { definitions, reference } = collectLabels(
28
+ tree.rootNode,
29
+ document.offsetAt(position),
30
+ );
31
+
32
+ if (reference === undefined) {
33
+ return null;
34
+ }
35
+
36
+ const locations = definitions
37
+ .filter((definition) => definition.text === reference.text)
38
+ .map((definition) => ({
39
+ uri: document.uri,
40
+ range: rangeForNode(document, definition),
41
+ }));
42
+
43
+ return locations.length === 0 ? null : locations;
44
+ }
@@ -0,0 +1,119 @@
1
+ import { DiagnosticSeverity } from "vscode-languageserver/node";
2
+ import {
3
+ collectSyntaxIssueNodes,
4
+ rangeForNode,
5
+ syntaxTreeFor,
6
+ } from "./syntax.js";
7
+
8
+ const diagnosticSource = "sed-language-server";
9
+
10
+ const issueDescriptions = {
11
+ incomplete_escape: {
12
+ code: "incomplete-escape",
13
+ message: "This escape sequence is incomplete.",
14
+ },
15
+ incomplete_regex: {
16
+ code: "unterminated-regular-expression",
17
+ message: "This regular expression is not terminated.",
18
+ },
19
+ incomplete_replacement: {
20
+ code: "unterminated-replacement",
21
+ message: "This replacement is not terminated.",
22
+ },
23
+ incomplete_translate: {
24
+ code: "unterminated-translation",
25
+ message: "This translation is not terminated.",
26
+ },
27
+ invalid_control_escape: {
28
+ code: "invalid-control-escape",
29
+ message: "This GNU sed control escape is invalid.",
30
+ },
31
+ unclosed_bracket: {
32
+ code: "unclosed-bracket-expression",
33
+ message: "This bracket expression is not terminated.",
34
+ },
35
+ invalid_command: (node, dialect) => ({
36
+ code: "invalid-command",
37
+ message: `Unknown ${dialectName(dialect)} sed command: \`${node.text}\`.`,
38
+ }),
39
+ invalid_flag: (node, dialect) => ({
40
+ code: "invalid-substitution-flag",
41
+ message: `Unknown ${dialectName(dialect)} sed substitution flag: \`${node.text}\`.`,
42
+ }),
43
+ unexpected_text: (_node, dialect) => ({
44
+ code: "unexpected-text",
45
+ message: `Unexpected text after this ${dialectName(dialect)} sed command.`,
46
+ }),
47
+ };
48
+
49
+ function dialectName(dialect) {
50
+ return dialect === "gnu" ? "GNU" : "POSIX";
51
+ }
52
+
53
+ function descriptionFor(node, dialect) {
54
+ const description = issueDescriptions[node.type];
55
+ if (typeof description === "function") {
56
+ return description(node, dialect);
57
+ }
58
+ if (description !== undefined) {
59
+ return description;
60
+ }
61
+
62
+ if (node.isMissing) {
63
+ return {
64
+ code: "missing-syntax",
65
+ message: `Expected ${node.type.replaceAll("_", " ")}.`,
66
+ };
67
+ }
68
+
69
+ return {
70
+ code: "syntax-error",
71
+ message: `Invalid ${dialectName(dialect)} sed syntax.`,
72
+ };
73
+ }
74
+ function issuePriority(node) {
75
+ if (node.isError) {
76
+ return 0;
77
+ }
78
+ if (node.isMissing) {
79
+ return 1;
80
+ }
81
+ return 2;
82
+ }
83
+
84
+ function preferSpecificIssues(nodes) {
85
+ const issuesByRange = new Map();
86
+
87
+ for (const node of nodes) {
88
+ const key = `${node.startIndex}:${node.endIndex}`;
89
+ const existing = issuesByRange.get(key);
90
+ if (
91
+ existing === undefined ||
92
+ issuePriority(node) > issuePriority(existing)
93
+ ) {
94
+ issuesByRange.set(key, node);
95
+ }
96
+ }
97
+
98
+ return [...issuesByRange.values()].sort(
99
+ (left, right) =>
100
+ left.startIndex - right.startIndex || left.endIndex - right.endIndex,
101
+ );
102
+ }
103
+
104
+ export function createDiagnostics(document, dialect) {
105
+ const tree = syntaxTreeFor(document, dialect);
106
+
107
+ return preferSpecificIssues(collectSyntaxIssueNodes(tree.rootNode)).map(
108
+ (node) => {
109
+ const { code, message } = descriptionFor(node, dialect);
110
+ return {
111
+ severity: DiagnosticSeverity.Error,
112
+ range: rangeForNode(document, node),
113
+ message,
114
+ code,
115
+ source: diagnosticSource,
116
+ };
117
+ },
118
+ );
119
+ }
@@ -0,0 +1,97 @@
1
+ import { collectSyntaxIssueNodes, syntaxTreeFor } from "./syntax.js";
2
+
3
+ function formatCommandList(node, indentation, lineEnding, depth) {
4
+ return node.children
5
+ .filter((child) => child.type === "command")
6
+ .map((command) => formatCommand(command, indentation, lineEnding, depth))
7
+ .join(lineEnding);
8
+ }
9
+
10
+ function formatCommand(command, indentation, lineEnding, depth) {
11
+ const prefix = indentation.repeat(depth);
12
+ const body = command.childForFieldName("body");
13
+ if (body.type !== "block_command") {
14
+ return `${prefix}${command.text}`;
15
+ }
16
+
17
+ const openingBrace = body.childForFieldName("name");
18
+ const opening = command.text.slice(
19
+ 0,
20
+ openingBrace.endIndex - command.startIndex,
21
+ );
22
+ const commandList = body.childForFieldName("argument");
23
+ if (commandList === null) {
24
+ return `${prefix}${opening}}`;
25
+ }
26
+
27
+ const formattedCommands = formatCommandList(
28
+ commandList,
29
+ indentation,
30
+ lineEnding,
31
+ depth + 1,
32
+ );
33
+ const content =
34
+ formattedCommands === "" ? "" : `${formattedCommands}${lineEnding}`;
35
+
36
+ return `${prefix}${opening}${lineEnding}${content}${prefix}}`;
37
+ }
38
+
39
+ function indentationFor(options) {
40
+ return options.insertSpaces ? " ".repeat(options.tabSize) : "\t";
41
+ }
42
+
43
+ function formatScript(rootNode, source, options) {
44
+ const lineEnding = source.match(/\r\n|\n/)?.[0] ?? "\n";
45
+ const indentation = indentationFor(options);
46
+ const firstLine = rootNode.children.find(
47
+ (child) => child.type === "first_line_silent",
48
+ );
49
+ const commandList = rootNode.children.find(
50
+ (child) => child.type === "command_list",
51
+ );
52
+ const sections = [];
53
+
54
+ if (firstLine !== undefined) {
55
+ sections.push(firstLine.text);
56
+ }
57
+ if (commandList !== undefined) {
58
+ const formattedCommands = formatCommandList(
59
+ commandList,
60
+ indentation,
61
+ lineEnding,
62
+ 0,
63
+ );
64
+ if (formattedCommands !== "") {
65
+ sections.push(formattedCommands);
66
+ }
67
+ }
68
+
69
+ let formatted = sections.join(lineEnding);
70
+ if (source.endsWith("\n") && !formatted.endsWith("\n")) {
71
+ formatted += lineEnding;
72
+ }
73
+ return formatted;
74
+ }
75
+
76
+ export function createFormattingEdits(document, dialect, options) {
77
+ const source = document.getText();
78
+ const tree = syntaxTreeFor(document, dialect);
79
+ if (collectSyntaxIssueNodes(tree.rootNode).length > 0) {
80
+ return [];
81
+ }
82
+
83
+ const formatted = formatScript(tree.rootNode, source, options);
84
+ if (formatted === source) {
85
+ return [];
86
+ }
87
+
88
+ return [
89
+ {
90
+ range: {
91
+ start: document.positionAt(0),
92
+ end: document.positionAt(source.length),
93
+ },
94
+ newText: formatted,
95
+ },
96
+ ];
97
+ }
package/src/server.js CHANGED
@@ -11,11 +11,10 @@ import {
11
11
  TextDocuments,
12
12
  } from "vscode-languageserver/node";
13
13
  import { TextDocument } from "vscode-languageserver-textdocument";
14
- import {
15
- createDefinitionLocations,
16
- createDiagnostics,
17
- invalidateSyntaxTreeCache,
18
- } from "./analysis.js";
14
+ import { createDefinitionLocations } from "./definition.js";
15
+ import { createDiagnostics } from "./diagnostics.js";
16
+ import { createFormattingEdits } from "./formatting.js";
17
+ import { invalidateSyntaxTreeCache } from "./syntax.js";
19
18
 
20
19
  if (process.argv.length === 2) {
21
20
  process.argv.push("--stdio");
@@ -81,6 +80,7 @@ connection.onInitialize(({ initializationOptions }) => {
81
80
  capabilities: {
82
81
  textDocumentSync: TextDocumentSyncKind.Incremental,
83
82
  definitionProvider: true,
83
+ documentFormattingProvider: true,
84
84
  },
85
85
  };
86
86
  });
@@ -108,6 +108,13 @@ connection.onDefinition(({ textDocument, position }) => {
108
108
  : createDefinitionLocations(document, position, activeDialect);
109
109
  });
110
110
 
111
+ connection.onDocumentFormatting(({ textDocument, options }) => {
112
+ const document = documents.get(textDocument.uri);
113
+ return document === undefined
114
+ ? []
115
+ : createFormattingEdits(document, activeDialect, options);
116
+ });
117
+
111
118
  documents.onDidChangeContent(({ document }) => {
112
119
  publishDiagnostics(document);
113
120
  });
package/src/syntax.js ADDED
@@ -0,0 +1,124 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { Language, Parser } from "web-tree-sitter";
3
+
4
+ await Parser.init();
5
+
6
+ async function createParser(dialect) {
7
+ const path = fileURLToPath(
8
+ new URL(`../vendor/tree-sitter-sed-${dialect}.wasm`, import.meta.url),
9
+ );
10
+ return new Parser().setLanguage(await Language.load(path));
11
+ }
12
+
13
+ const parsers = {
14
+ posix: await createParser("posix"),
15
+ gnu: await createParser("gnu"),
16
+ };
17
+
18
+ const documentTrees = new Map();
19
+
20
+ export function syntaxTreeFor(document, dialect) {
21
+ const source = document.getText();
22
+ let trees = documentTrees.get(document.uri);
23
+
24
+ if (trees === undefined) {
25
+ trees = new Map();
26
+ documentTrees.set(document.uri, trees);
27
+ }
28
+
29
+ const cached = trees.get(dialect);
30
+ if (
31
+ cached !== undefined &&
32
+ cached.version === document.version &&
33
+ cached.source === source
34
+ ) {
35
+ return cached.tree;
36
+ }
37
+
38
+ cached?.tree.delete();
39
+ const tree = parsers[dialect].parse(source);
40
+ if (tree === null) {
41
+ throw new Error(`Failed to parse ${dialect} sed source.`);
42
+ }
43
+
44
+ trees.set(dialect, {
45
+ source,
46
+ tree,
47
+ version: document.version,
48
+ });
49
+ return tree;
50
+ }
51
+
52
+ export function invalidateSyntaxTreeCache(document) {
53
+ const trees = documentTrees.get(document.uri);
54
+ if (trees !== undefined) {
55
+ for (const { tree } of trees.values()) {
56
+ tree.delete();
57
+ }
58
+ documentTrees.delete(document.uri);
59
+ }
60
+ }
61
+
62
+ function isSpecificSyntaxIssue(node) {
63
+ return (
64
+ node.isMissing ||
65
+ node.type.startsWith("incomplete_") ||
66
+ node.type.startsWith("invalid_") ||
67
+ node.type === "unclosed_bracket" ||
68
+ node.type === "unexpected_text"
69
+ );
70
+ }
71
+
72
+ export function collectSyntaxIssueNodes(rootNode) {
73
+ const issues = [];
74
+ const stack = [
75
+ {
76
+ node: rootNode,
77
+ children: rootNode.children,
78
+ childIndex: 0,
79
+ hasDescendantIssue: false,
80
+ issueStartIndex: 0,
81
+ },
82
+ ];
83
+
84
+ while (stack.length > 0) {
85
+ const frame = stack.at(-1);
86
+ if (frame.childIndex < frame.children.length) {
87
+ const child = frame.children[frame.childIndex];
88
+ frame.childIndex += 1;
89
+ stack.push({
90
+ node: child,
91
+ children: child.children,
92
+ childIndex: 0,
93
+ hasDescendantIssue: false,
94
+ issueStartIndex: issues.length,
95
+ });
96
+ continue;
97
+ }
98
+
99
+ const specific = isSpecificSyntaxIssue(frame.node);
100
+ const selected =
101
+ specific || (frame.node.isError && !frame.hasDescendantIssue);
102
+ if (specific) {
103
+ issues.length = frame.issueStartIndex;
104
+ }
105
+ if (selected) {
106
+ issues.push(frame.node);
107
+ }
108
+
109
+ stack.pop();
110
+ const parent = stack.at(-1);
111
+ if (parent !== undefined) {
112
+ parent.hasDescendantIssue ||= frame.hasDescendantIssue || selected;
113
+ }
114
+ }
115
+
116
+ return issues;
117
+ }
118
+
119
+ export function rangeForNode(document, node) {
120
+ return {
121
+ start: document.positionAt(node.startIndex),
122
+ end: document.positionAt(node.endIndex),
123
+ };
124
+ }
package/src/analysis.js DELETED
@@ -1,275 +0,0 @@
1
- import { fileURLToPath } from "node:url";
2
- import { DiagnosticSeverity } from "vscode-languageserver/node";
3
- import { Language, Parser } from "web-tree-sitter";
4
-
5
- await Parser.init();
6
-
7
- async function createParser(dialect) {
8
- const path = fileURLToPath(
9
- new URL(`../vendor/tree-sitter-sed-${dialect}.wasm`, import.meta.url),
10
- );
11
- return new Parser().setLanguage(await Language.load(path));
12
- }
13
-
14
- const parsers = {
15
- posix: await createParser("posix"),
16
- gnu: await createParser("gnu"),
17
- };
18
-
19
- const documentTrees = new Map();
20
-
21
- function parseDocument(document, dialect) {
22
- const source = document.getText();
23
- let trees = documentTrees.get(document.uri);
24
-
25
- if (trees === undefined) {
26
- trees = new Map();
27
- documentTrees.set(document.uri, trees);
28
- }
29
-
30
- const cached = trees.get(dialect);
31
- if (
32
- cached !== undefined &&
33
- cached.version === document.version &&
34
- cached.source === source
35
- ) {
36
- return cached.tree;
37
- }
38
-
39
- cached?.tree.delete();
40
- const tree = parsers[dialect].parse(source);
41
- if (tree === null) {
42
- throw new Error(`Failed to parse ${dialect} sed source.`);
43
- }
44
-
45
- trees.set(dialect, {
46
- source,
47
- tree,
48
- version: document.version,
49
- });
50
- return tree;
51
- }
52
-
53
- export function invalidateSyntaxTreeCache(document) {
54
- const trees = documentTrees.get(document.uri);
55
- if (trees !== undefined) {
56
- for (const { tree } of trees.values()) {
57
- tree.delete();
58
- }
59
- documentTrees.delete(document.uri);
60
- }
61
- }
62
-
63
- const diagnosticSource = "sed-language-server";
64
-
65
- const issueDescriptions = {
66
- incomplete_escape: {
67
- code: "incomplete-escape",
68
- message: "This escape sequence is incomplete.",
69
- },
70
- incomplete_regex: {
71
- code: "unterminated-regular-expression",
72
- message: "This regular expression is not terminated.",
73
- },
74
- incomplete_replacement: {
75
- code: "unterminated-replacement",
76
- message: "This replacement is not terminated.",
77
- },
78
- incomplete_translate: {
79
- code: "unterminated-translation",
80
- message: "This translation is not terminated.",
81
- },
82
- invalid_control_escape: {
83
- code: "invalid-control-escape",
84
- message: "This GNU sed control escape is invalid.",
85
- },
86
- unclosed_bracket: {
87
- code: "unclosed-bracket-expression",
88
- message: "This bracket expression is not terminated.",
89
- },
90
- invalid_command: (node, dialect) => ({
91
- code: "invalid-command",
92
- message: `Unknown ${dialectName(dialect)} sed command: \`${node.text}\`.`,
93
- }),
94
- invalid_flag: (node, dialect) => ({
95
- code: "invalid-substitution-flag",
96
- message: `Unknown ${dialectName(dialect)} sed substitution flag: \`${node.text}\`.`,
97
- }),
98
- unexpected_text: (_node, dialect) => ({
99
- code: "unexpected-text",
100
- message: `Unexpected text after this ${dialectName(dialect)} sed command.`,
101
- }),
102
- };
103
-
104
- function dialectName(dialect) {
105
- return dialect === "gnu" ? "GNU" : "POSIX";
106
- }
107
-
108
- function descriptionFor(node, dialect) {
109
- const description = issueDescriptions[node.type];
110
- if (typeof description === "function") {
111
- return description(node, dialect);
112
- }
113
- if (description !== undefined) {
114
- return description;
115
- }
116
-
117
- const displayName = dialectName(dialect);
118
- if (node.isMissing) {
119
- return {
120
- code: "missing-syntax",
121
- message: `Expected ${node.type.replaceAll("_", " ")}.`,
122
- };
123
- }
124
-
125
- return {
126
- code: "syntax-error",
127
- message: `Invalid ${displayName} sed syntax.`,
128
- };
129
- }
130
-
131
- function isSpecificIssue(node) {
132
- return node.isMissing || Object.hasOwn(issueDescriptions, node.type);
133
- }
134
-
135
- function collectIssueNodes(rootNode) {
136
- const issues = [];
137
- const stack = [
138
- {
139
- node: rootNode,
140
- children: rootNode.children,
141
- childIndex: 0,
142
- hasDescendantIssue: false,
143
- issueStartIndex: 0,
144
- },
145
- ];
146
-
147
- while (stack.length > 0) {
148
- const frame = stack.at(-1);
149
- if (frame.childIndex < frame.children.length) {
150
- const child = frame.children[frame.childIndex];
151
- frame.childIndex += 1;
152
- stack.push({
153
- node: child,
154
- children: child.children,
155
- childIndex: 0,
156
- hasDescendantIssue: false,
157
- issueStartIndex: issues.length,
158
- });
159
- continue;
160
- }
161
-
162
- const specific = isSpecificIssue(frame.node);
163
- const selected =
164
- specific || (frame.node.isError && !frame.hasDescendantIssue);
165
- if (specific) {
166
- issues.length = frame.issueStartIndex;
167
- }
168
- if (selected) {
169
- issues.push(frame.node);
170
- }
171
-
172
- stack.pop();
173
- const parent = stack.at(-1);
174
- if (parent !== undefined) {
175
- parent.hasDescendantIssue ||= frame.hasDescendantIssue || selected;
176
- }
177
- }
178
-
179
- return issues;
180
- }
181
-
182
- function issuePriority(node) {
183
- if (node.isError) {
184
- return 0;
185
- }
186
- if (node.isMissing) {
187
- return 1;
188
- }
189
- return 2;
190
- }
191
-
192
- function preferSpecificIssues(nodes) {
193
- const issuesByRange = new Map();
194
-
195
- for (const node of nodes) {
196
- const key = `${node.startIndex}:${node.endIndex}`;
197
- const existing = issuesByRange.get(key);
198
- if (
199
- existing === undefined ||
200
- issuePriority(node) > issuePriority(existing)
201
- ) {
202
- issuesByRange.set(key, node);
203
- }
204
- }
205
-
206
- return [...issuesByRange.values()].sort(
207
- (left, right) =>
208
- left.startIndex - right.startIndex || left.endIndex - right.endIndex,
209
- );
210
- }
211
-
212
- function rangeForNode(document, node) {
213
- return {
214
- start: document.positionAt(node.startIndex),
215
- end: document.positionAt(node.endIndex),
216
- };
217
- }
218
-
219
- function collectLabels(rootNode, offset) {
220
- const definitions = [];
221
- let reference;
222
- const stack = [rootNode];
223
-
224
- while (stack.length > 0) {
225
- const node = stack.pop();
226
- if (node.type === "label_definition") {
227
- definitions.push(node);
228
- } else if (
229
- node.type === "label_reference" &&
230
- node.startIndex <= offset &&
231
- offset < node.endIndex
232
- ) {
233
- reference = node;
234
- }
235
- stack.push(...node.children.toReversed());
236
- }
237
-
238
- return { definitions, reference };
239
- }
240
-
241
- export function createDiagnostics(document, dialect) {
242
- const tree = parseDocument(document, dialect);
243
-
244
- return preferSpecificIssues(collectIssueNodes(tree.rootNode)).map((node) => {
245
- const { code, message } = descriptionFor(node, dialect);
246
- return {
247
- severity: DiagnosticSeverity.Error,
248
- range: rangeForNode(document, node),
249
- message,
250
- code,
251
- source: diagnosticSource,
252
- };
253
- });
254
- }
255
-
256
- export function createDefinitionLocations(document, position, dialect) {
257
- const tree = parseDocument(document, dialect);
258
- const { definitions, reference } = collectLabels(
259
- tree.rootNode,
260
- document.offsetAt(position),
261
- );
262
-
263
- if (reference === undefined) {
264
- return null;
265
- }
266
-
267
- const locations = definitions
268
- .filter((definition) => definition.text === reference.text)
269
- .map((definition) => ({
270
- uri: document.uri,
271
- range: rangeForNode(document, definition),
272
- }));
273
-
274
- return locations.length === 0 ? null : locations;
275
- }