@tsuzuri-lab/sed-language-server 0.1.2 → 0.2.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
@@ -2,13 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/tsuzuri-lab/sed-language-server/actions/workflows/ci.yml/badge.svg)](https://github.com/tsuzuri-lab/sed-language-server/actions/workflows/ci.yml)
4
4
 
5
- A [Language Server Protocol](https://microsoft.github.io/language-server-protocol/)
6
- implementation for
7
- [POSIX `sed`](https://pubs.opengroup.org/onlinepubs/9799919799.2024edition/utilities/sed.html)
8
- with opt-in GNU sed 4.10 syntax support.
9
-
10
- The default profile is POSIX with basic regular expressions. BSD and
11
- other implementation-specific extensions are outside the current scope.
5
+ A Language Server Protocol implementation for POSIX and GNU `sed`.
12
6
 
13
7
  ## Installation
14
8
 
@@ -18,7 +12,7 @@ Requires Node.js 22 or later.
18
12
  npm install --global @tsuzuri-lab/sed-language-server
19
13
  ```
20
14
 
21
- Configure the LSP client to start the server with:
15
+ Start the server with:
22
16
 
23
17
  ```sh
24
18
  sed-language-server --stdio
@@ -26,49 +20,59 @@ sed-language-server --stdio
26
20
 
27
21
  ## Features
28
22
 
29
- The server provides:
30
-
31
- - profile-aware diagnostics for supported POSIX and GNU sed syntax
32
- - profile-aware command, substitute-flag, and branch-label completion
23
+ - Tree-sitter-based diagnostics
33
24
  - go to definition from `b`, `t`, and GNU `T` label references
34
25
 
35
- It analyzes scripts without executing them, running shell commands, or
36
- accessing referenced files.
37
-
38
- ## Syntax profiles
26
+ Scripts are analyzed without executing them or accessing referenced files.
39
27
 
40
- The supported profile values are:
28
+ ## Configuration
41
29
 
42
- - `dialect`: `posix` or `gnu`
43
- - `regexpMode`: `bre` or `ere`
44
-
45
- Clients can select a profile during initialization:
30
+ The default dialect is `posix`. To use GNU syntax, pass the following LSP
31
+ initialization options:
46
32
 
47
33
  ```json
48
34
  {
49
- "initializationOptions": {
50
- "dialect": "gnu",
51
- "regexpMode": "bre"
52
- }
35
+ "dialect": "gnu"
53
36
  }
54
37
  ```
55
38
 
56
- They can change it for all open documents through
57
- `workspace/didChangeConfiguration`:
39
+ The supported dialects are `posix` and `gnu`.
58
40
 
59
- ```json
60
- {
61
- "settings": {
62
- "sedLanguageServer": {
63
- "dialect": "gnu",
64
- "regexpMode": "ere"
65
- }
66
- }
67
- }
41
+ ## Editor setup
42
+
43
+ Configure the LSP client to use:
44
+
45
+ - command: `sed-language-server --stdio`
46
+ - language ID or filetype: `sed`
47
+ - initialization options: `{"dialect":"gnu"}` when using GNU syntax
48
+
49
+ For example, with Emacs and Eglot:
50
+
51
+ ```elisp
52
+ (require 'eglot)
53
+
54
+ (add-to-list 'eglot-server-programs
55
+ '((sed-ts-mode sed-mode) .
56
+ ("sed-language-server" "--stdio"
57
+ :initializationOptions (:dialect "gnu"))))
58
+ ```
59
+
60
+ Run `M-x eglot` in a `sed-ts-mode` or `sed-mode` buffer. Omit
61
+ `:initializationOptions` to use the default POSIX dialect.
62
+
63
+ For Neovim 0.11 or later:
64
+
65
+ ```lua
66
+ vim.lsp.config("sed_language_server", {
67
+ cmd = { "sed-language-server", "--stdio" },
68
+ filetypes = { "sed" },
69
+ init_options = { dialect = "gnu" },
70
+ })
71
+
72
+ vim.lsp.enable("sed_language_server")
68
73
  ```
69
74
 
70
- Omitted settings select `posix` and `bre`. The server does not infer a dialect
71
- from document contents.
75
+ Omit `init_options` to use the default POSIX dialect.
72
76
 
73
77
  ## Development
74
78
 
@@ -76,7 +80,13 @@ from document contents.
76
80
  npm ci
77
81
  npm run check
78
82
  npm test
79
- npm start
83
+ ```
84
+
85
+ The published package includes prebuilt POSIX and GNU grammar Wasm files.
86
+ Rebuild them from the pinned `tree-sitter-sed` revision with:
87
+
88
+ ```sh
89
+ npm run build:grammar -- /path/to/tree-sitter-sed
80
90
  ```
81
91
 
82
92
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsuzuri-lab/sed-language-server",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Language server for POSIX sed with opt-in GNU syntax support.",
5
5
  "keywords": [
6
6
  "sed",
@@ -9,12 +9,14 @@
9
9
  ],
10
10
  "type": "module",
11
11
  "files": [
12
- "src"
12
+ "src",
13
+ "vendor"
13
14
  ],
14
15
  "bin": {
15
16
  "sed-language-server": "src/server.js"
16
17
  },
17
18
  "scripts": {
19
+ "build:grammar": "node scripts/build-tree-sitter-sed-wasm.js",
18
20
  "check": "biome check .",
19
21
  "check:write": "biome check --write .",
20
22
  "prepublishOnly": "npm run check && npm test",
@@ -38,9 +40,11 @@
38
40
  },
39
41
  "dependencies": {
40
42
  "vscode-languageserver": "10.1.0",
41
- "vscode-languageserver-textdocument": "1.0.12"
43
+ "vscode-languageserver-textdocument": "1.0.12",
44
+ "web-tree-sitter": "0.26.11"
42
45
  },
43
46
  "devDependencies": {
44
- "@biomejs/biome": "2.5.5"
47
+ "@biomejs/biome": "2.5.5",
48
+ "tree-sitter-cli": "0.26.11"
45
49
  }
46
50
  }
@@ -0,0 +1,275 @@
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
+ }
package/src/server.js CHANGED
@@ -12,16 +12,10 @@ import {
12
12
  } from "vscode-languageserver/node";
13
13
  import { TextDocument } from "vscode-languageserver-textdocument";
14
14
  import {
15
- completionProviderOptions,
16
- createCompletionHandler,
17
- } from "./completion.js";
18
- import { createDefinitionHandler, definitionProvider } from "./definition.js";
19
- import { createDiagnostics } from "./diagnostics.js";
20
- import { invalidateDocumentStructureCache } from "./document-structure.js";
21
- import {
22
- defaultSyntaxProfile,
23
- resolveSyntaxProfile,
24
- } from "./syntax-profile.js";
15
+ createDefinitionLocations,
16
+ createDiagnostics,
17
+ invalidateSyntaxTreeCache,
18
+ } from "./analysis.js";
25
19
 
26
20
  if (process.argv.length === 2) {
27
21
  process.argv.push("--stdio");
@@ -29,19 +23,31 @@ if (process.argv.length === 2) {
29
23
 
30
24
  const connection = createConnection(ProposedFeatures.all);
31
25
  const documents = new TextDocuments(TextDocument);
32
- let activeSyntaxProfile = defaultSyntaxProfile;
26
+ const defaultDialect = "posix";
27
+ let activeDialect = defaultDialect;
33
28
 
34
- const serverCapabilities = {
35
- textDocumentSync: TextDocumentSyncKind.Incremental,
36
- completionProvider: completionProviderOptions,
37
- definitionProvider,
38
- };
29
+ function resolveDialect(options) {
30
+ if (options === undefined || options === null) {
31
+ return { dialect: defaultDialect };
32
+ }
33
+ if (typeof options !== "object" || Array.isArray(options)) {
34
+ return {
35
+ error: "Syntax options must be provided as an object or null.",
36
+ };
37
+ }
39
38
 
40
- function syntaxProfileErrorMessage(errors) {
41
- return errors.map(({ message }) => message).join(" ");
39
+ const dialect =
40
+ options.dialect === undefined ? defaultDialect : options.dialect;
41
+ if (dialect !== "posix" && dialect !== "gnu") {
42
+ return {
43
+ error: 'The syntax dialect must be either "posix" or "gnu".',
44
+ };
45
+ }
46
+
47
+ return { dialect };
42
48
  }
43
49
 
44
- function configurationProfileOptions(settings) {
50
+ function configurationOptions(settings) {
45
51
  if (
46
52
  settings !== null &&
47
53
  typeof settings === "object" &&
@@ -54,63 +60,60 @@ function configurationProfileOptions(settings) {
54
60
  return settings;
55
61
  }
56
62
 
57
- function publishDiagnostics(document, syntaxProfile = activeSyntaxProfile) {
63
+ function publishDiagnostics(document) {
58
64
  return connection.sendDiagnostics({
59
65
  uri: document.uri,
60
66
  version: document.version,
61
- diagnostics: createDiagnostics(document, syntaxProfile),
67
+ diagnostics: createDiagnostics(document, activeDialect),
62
68
  });
63
69
  }
64
70
 
65
71
  connection.onInitialize(({ initializationOptions }) => {
66
- const result = resolveSyntaxProfile(initializationOptions);
67
- if (!result.ok) {
68
- return new ResponseError(
69
- ErrorCodes.InvalidParams,
70
- syntaxProfileErrorMessage(result.errors),
71
- { retry: false },
72
- );
72
+ const result = resolveDialect(initializationOptions);
73
+ if (result.error !== undefined) {
74
+ return new ResponseError(ErrorCodes.InvalidParams, result.error, {
75
+ retry: false,
76
+ });
73
77
  }
74
78
 
75
- activeSyntaxProfile = result.profile;
79
+ activeDialect = result.dialect;
76
80
  return {
77
- capabilities: serverCapabilities,
81
+ capabilities: {
82
+ textDocumentSync: TextDocumentSyncKind.Incremental,
83
+ definitionProvider: true,
84
+ },
78
85
  };
79
86
  });
80
87
 
81
88
  connection.onDidChangeConfiguration(async ({ settings }) => {
82
- const result = resolveSyntaxProfile(configurationProfileOptions(settings));
83
- if (!result.ok) {
89
+ const result = resolveDialect(configurationOptions(settings));
90
+ if (result.error !== undefined) {
84
91
  connection.sendNotification(ShowMessageNotification.type, {
85
92
  type: MessageType.Error,
86
- message: syntaxProfileErrorMessage(result.errors),
93
+ message: result.error,
87
94
  });
88
95
  return;
89
96
  }
90
97
 
91
- const nextSyntaxProfile = result.profile;
92
- activeSyntaxProfile = nextSyntaxProfile;
93
- invalidateDocumentStructureCache();
98
+ activeDialect = result.dialect;
94
99
  await Promise.all(
95
- documents
96
- .all()
97
- .map((document) => publishDiagnostics(document, nextSyntaxProfile)),
100
+ documents.all().map((document) => publishDiagnostics(document)),
98
101
  );
99
102
  });
100
103
 
101
- connection.onCompletion(
102
- createCompletionHandler(documents, () => activeSyntaxProfile),
103
- );
104
- connection.onDefinition(
105
- createDefinitionHandler(documents, () => activeSyntaxProfile),
106
- );
104
+ connection.onDefinition(({ textDocument, position }) => {
105
+ const document = documents.get(textDocument.uri);
106
+ return document === undefined
107
+ ? null
108
+ : createDefinitionLocations(document, position, activeDialect);
109
+ });
107
110
 
108
111
  documents.onDidChangeContent(({ document }) => {
109
112
  publishDiagnostics(document);
110
113
  });
111
114
 
112
115
  documents.onDidClose(({ document }) => {
113
- invalidateDocumentStructureCache(document);
116
+ invalidateSyntaxTreeCache(document);
114
117
  connection.sendDiagnostics({ uri: document.uri, diagnostics: [] });
115
118
  });
116
119
 
Binary file