diff --git a/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/AllCleanRule.java b/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/AllCleanRule.java index 9e8e49b0a6..7fb899b180 100644 --- a/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/AllCleanRule.java +++ b/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/AllCleanRule.java @@ -74,10 +74,12 @@ public static void closeIntro() { public static void enableLogging() { ScopedPreferenceStore prefs = new ScopedPreferenceStore(InstanceScope.INSTANCE, "org.eclipse.lsp4e"); prefs.putValue("org.eclipse.wildwebdeveloper.angular.file.logging.enabled", Boolean.toString(true)); + prefs.putValue("org.eclipse.wildwebdeveloper.astro.file.logging.enabled", Boolean.toString(true)); prefs.putValue("org.eclipse.wildwebdeveloper.jsts.file.logging.enabled", Boolean.toString(true)); prefs.putValue("org.eclipse.wildwebdeveloper.css.file.logging.enabled", Boolean.toString(true)); prefs.putValue("org.eclipse.wildwebdeveloper.html.file.logging.enabled", Boolean.toString(true)); prefs.putValue("org.eclipse.wildwebdeveloper.json.file.logging.enabled", Boolean.toString(true)); + prefs.putValue("org.eclipse.wildwebdeveloper.markdown.file.logging.enabled", Boolean.toString(true)); prefs.putValue("org.eclipse.wildwebdeveloper.xml.file.logging.enabled", Boolean.toString(true)); prefs.putValue("org.eclipse.wildwebdeveloper.yaml.file.logging.enabled", Boolean.toString(true)); prefs.putValue("org.eclipse.wildwebdeveloper.eslint.file.logging.enabled", Boolean.toString(true)); diff --git a/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/TestMarkdown.java b/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/TestMarkdown.java new file mode 100644 index 0000000000..90ad518d80 --- /dev/null +++ b/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/TestMarkdown.java @@ -0,0 +1,126 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.wildwebdeveloper.tests; + +import static org.eclipse.core.resources.IMarker.*; +import static org.eclipse.wildwebdeveloper.markdown.MarkdownDiagnosticsManager.MARKDOWN_MARKER_TYPE; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServerWrapper; +import org.eclipse.lsp4e.LanguageServiceAccessor; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.editors.text.TextEditor; +import org.eclipse.ui.ide.IDE; +import org.eclipse.ui.tests.harness.util.DisplayHelper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +record MarkdownTest(String markdown, String messagePattern, int severity) { +} + +@ExtendWith(AllCleanRule.class) +class TestMarkdown { + + @Test + void diagnosticsCoverTypicalMarkdownIssues() throws Exception { + var project = ResourcesPlugin.getWorkspace().getRoot().getProject(getClass().getName() + System.nanoTime()); + project.create(null); + project.open(null); + + final var markerTests = Collections.synchronizedCollection(new ArrayList()); + markerTests + .add(new MarkdownTest("Reference link to [an undefined reference][missing-ref]", "No link definition found: 'missing-ref'", + SEVERITY_WARNING)); + markerTests.add( + new MarkdownTest("Relative file link: [data](./nonexistent-folder/data.csv)", "File does not exist at path: .*data\\.csv", + SEVERITY_WARNING)); + markerTests.add(new MarkdownTest("Broken image: ![logo](../assets/logo.png)", "File does not exist at path: .*logo\\.png", + SEVERITY_WARNING)); + markerTests.add(new MarkdownTest("Link to missing header in this file: [Jump to Setup](#setup)", "No header found: 'setup'", + SEVERITY_WARNING)); + markerTests.add(new MarkdownTest("Link to missing header in another file: [See Guide](./GUIDE.md#installing)", + "Header does not exist in file: installing", + SEVERITY_WARNING)); + markerTests.add(new MarkdownTest("Undefined footnote here [^missing-footnote]", "No link definition found: '\\^missing-footnote'", + SEVERITY_WARNING)); + markerTests.add(new MarkdownTest("This is a paragraph with an [undefined link][undefined-link].", + "No link definition found: 'undefined-link'", + SEVERITY_WARNING)); + markerTests.add(new MarkdownTest("[unused-link]: https://unused-link.com", "Link definition is unused", + SEVERITY_WARNING)); + markerTests.add(new MarkdownTest(""" + This is a paragraph with a [duplicate link][duplicate-link]. + [duplicate-link]: https://duplicate-link.com + [duplicate-link]: https://duplicate-link.com + """, "Link definition for 'duplicate-link' already exists", SEVERITY_ERROR)); + + final IFile referencedFile = project.getFile("GUIDE.md"); + referencedFile.create("".getBytes(), true, false, null); + + final IFile file = project.getFile("broken.md"); + file.create(markerTests.stream().map(MarkdownTest::markdown).collect(Collectors.joining("\n")).getBytes(StandardCharsets.UTF_8), + true, + false, null); + + final var editor = (TextEditor) IDE.openEditor(PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(), file); + final var display = editor.getSite().getShell().getDisplay(); + final var doc = editor.getDocumentProvider().getDocument(editor.getEditorInput()); + + /* + * ensure Markdown Language Server is started and connected + */ + final var markdownLS = new AtomicReference(); + DisplayHelper.waitForCondition(display, 10_000, () -> { + markdownLS.set(LanguageServiceAccessor.getStartedWrappers(doc, null, false).stream() // + .filter(w -> "org.eclipse.wildwebdeveloper.markdown".equals(w.serverDefinition.id)) // + .findFirst().orElse(null)); + return markdownLS.get() != null // + && markdownLS.get().isActive() // + && markdownLS.get().isConnectedTo(LSPEclipseUtils.toUri(doc)); + }); + + // Wait until all expected diagnostics are present (by message fragments) + DisplayHelper.waitForCondition(PlatformUI.getWorkbench().getDisplay(), 15_000, () -> { + try { + final var markers = file.findMarkers(MARKDOWN_MARKER_TYPE, true, IResource.DEPTH_ZERO); + if (markers.length == 0) + return false; + + for (final IMarker m : markers) { + final Object msgObj = m.getAttribute(IMarker.MESSAGE); + if (!(msgObj instanceof final String msg)) + continue; + markerTests.removeIf(t -> t.severity() == m.getAttribute(IMarker.SEVERITY, -1) && + msg.matches(t.messagePattern())); + } + return markerTests.isEmpty(); + } catch (CoreException e) { + return false; + } + }); + + assertTrue(markerTests.isEmpty(), "The following markers were not found: " + markerTests); + } +} diff --git a/org.eclipse.wildwebdeveloper/META-INF/MANIFEST.MF b/org.eclipse.wildwebdeveloper/META-INF/MANIFEST.MF index 1e0fb3cfa8..a4ee44274e 100644 --- a/org.eclipse.wildwebdeveloper/META-INF/MANIFEST.MF +++ b/org.eclipse.wildwebdeveloper/META-INF/MANIFEST.MF @@ -37,4 +37,5 @@ Bundle-ActivationPolicy: lazy Eclipse-BundleShape: dir Export-Package: org.eclipse.wildwebdeveloper.debug;x-internal:=true, org.eclipse.wildwebdeveloper.debug.node;x-internal:=true, - org.eclipse.wildwebdeveloper.debug.npm;x-internal:=true + org.eclipse.wildwebdeveloper.debug.npm;x-internal:=true, + org.eclipse.wildwebdeveloper.markdown;x-friends:="org.eclipse.wildwebdeveloper.tests" diff --git a/org.eclipse.wildwebdeveloper/package.json b/org.eclipse.wildwebdeveloper/package.json index b59f51a95e..5fb796d7fa 100644 --- a/org.eclipse.wildwebdeveloper/package.json +++ b/org.eclipse.wildwebdeveloper/package.json @@ -1,7 +1,7 @@ { "dependencies": { "@angular/language-server": "20.3.0", - "astro-vscode" : "2.16.0", + "astro-vscode": "2.16.0", "firefox-debugadapter": "2.15.0", "typescript": "5.9.3", "typescript-language-server": "5.0.1", @@ -11,14 +11,18 @@ "vscode-css-languageservice": "6.3.8", "vscode-html-languageservice": "5.6.0", "vscode-json-languageservice": "5.6.2", - "@vue/language-server" : "3.1.2", - "@vue/typescript-plugin" : "3.1.2", - "fsevents" : "2.3.3", + "vscode-markdown-languageserver": "^0.5.0-alpha.12", + "markdown-it": "14.1.0", + "@vue/language-server": "3.1.2", + "@vue/typescript-plugin": "3.1.2", "vscode-css-languageserver": "file:target/vscode-css-languageserver-1.0.0.tgz", "vscode-html-languageserver": "file:target/vscode-html-languageserver-1.0.0.tgz", "vscode-json-languageserver": "file:target/vscode-json-languageserver-1.3.4.tgz", "eslint-server": "file:target/eslint-server-2.4.1.tgz" }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, "overrides": { "vscode-languageserver-types": "3.17.6-next.6" } diff --git a/org.eclipse.wildwebdeveloper/plugin.properties b/org.eclipse.wildwebdeveloper/plugin.properties index 14de05e8a7..4e1039183a 100644 --- a/org.eclipse.wildwebdeveloper/plugin.properties +++ b/org.eclipse.wildwebdeveloper/plugin.properties @@ -45,6 +45,10 @@ TypeScriptInlayHintPreferencePage.name=Inlay Hint JavaScriptPreferencePage.name=JavaScript JavaScriptInlayHintPreferencePage.name=Inlay Hint +# Markdown +MarkdownPreferencePage.name=Markdown (Wild Web Developer) +MarkdownProblem=Markdown Problem + # YAML YAMLPreferencePage.name=YAML (Wild Web Developer) YAMLCompletionPreferencePage.name=Completion @@ -62,4 +66,5 @@ preferenceKeywords.css=css preferenceKeywords.less=less preferenceKeywords.scss=scss preferenceKeywords.sass=sass -preferenceKeywords.html=html \ No newline at end of file +preferenceKeywords.html=html +preferenceKeywords.markdown=markdown diff --git a/org.eclipse.wildwebdeveloper/plugin.xml b/org.eclipse.wildwebdeveloper/plugin.xml index 6d4a598354..f7c3eb3436 100644 --- a/org.eclipse.wildwebdeveloper/plugin.xml +++ b/org.eclipse.wildwebdeveloper/plugin.xml @@ -115,7 +115,7 @@ priority="low"> - + + + + + + + + + + + + + + + + + + + + + + - - + @@ -178,7 +209,7 @@ name="%CSSCompletionPreferencePage.name"> - + - + - + - + @@ -490,7 +521,7 @@ name="%JavaScriptInlayHintPreferencePage.name"> - + + @@ -604,9 +636,8 @@ contentTypeId="org.eclipse.wildwebdeveloper.vue" path="language-configurations/vue/vue-language-configuration.json"> - - + - + - + > configuration(ConfigurationParams configu // until the workspaceFolder which folder is best suited for its workingDirectoy (where the config files are in) // also this workspaceFolder is also used to find the node models (eslint module) // because we set the nodePath below to this same directory. - File highestPackageJsonDir = FileUtils.fromUri(configurationItem.getScopeUri()).getParentFile(); + File highestPackageJsonDir = FileUtils.uriToFile(configurationItem.getScopeUri()).getParentFile(); File parentFile = highestPackageJsonDir; while (parentFile != null) { if (new File(parentFile, "package.json").exists()) highestPackageJsonDir = parentFile; diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownDiagnosticsManager.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownDiagnosticsManager.java new file mode 100644 index 0000000000..833d89d2ab --- /dev/null +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownDiagnosticsManager.java @@ -0,0 +1,318 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.wildwebdeveloper.markdown; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.core.filebuffers.FileBuffers; +import org.eclipse.core.filebuffers.IFileBuffer; +import org.eclipse.core.filebuffers.IFileBufferListener; +import org.eclipse.core.filebuffers.LocationKind; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.ILog; +import org.eclipse.core.runtime.IPath; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.IDocument; +import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.LanguageServersRegistry; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticSeverity; +import org.eclipse.lsp4j.DocumentDiagnosticParams; +import org.eclipse.lsp4j.DocumentDiagnosticReport; +import org.eclipse.lsp4j.FullDocumentDiagnosticReport; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.RelatedFullDocumentDiagnosticReport; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.UnchangedDocumentDiagnosticReport; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.services.LanguageServer; + +/** + * Pulls diagnostics from the Markdown language server and maps them to Eclipse problem markers. + */ +public final class MarkdownDiagnosticsManager { + + public static final String MARKDOWN_MARKER_TYPE = "org.eclipse.wildwebdeveloper.markdown.problem"; + + static { + // Remove problem markers when a Markdown text buffer is disposed (editor closed) + FileBuffers.getTextFileBufferManager().addFileBufferListener(new IFileBufferListener() { + @Override + public void bufferContentAboutToBeReplaced(final IFileBuffer buffer) { + // no-op + } + + @Override + public void bufferContentReplaced(final IFileBuffer buffer) { + // no-op + } + + @Override + public void bufferCreated(final IFileBuffer buffer) { + // no-op + } + + @Override + public void bufferDisposed(final IFileBuffer buffer) { + final IPath location = buffer.getLocation(); + if (location == null) + return; + + /* + * remove all problem markers on editor close + */ + try { + final var root = ResourcesPlugin.getWorkspace().getRoot(); + final var res = root.findMember(location); + if (res instanceof final IFile file && file.exists()) { + final String name = file.getName().toLowerCase(); + if (name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) { + clearMarkers(file); + } + } + } catch (Exception ex) { + ILog.get().warn(ex.getMessage(), ex); + } + } + + @Override + public void dirtyStateChanged(final IFileBuffer buffer, final boolean isDirty) { + // no-op + } + + @Override + public void stateChangeFailed(final IFileBuffer buffer) { + // no-op + } + + @Override + public void stateChanging(final IFileBuffer buffer) { + // no-op + } + + @Override + public void stateValidationChanged(final IFileBuffer buffer, final boolean isStateValidated) { + // no-op + } + + @Override + public void underlyingFileDeleted(final IFileBuffer buffer) { + // no-op + } + + @Override + public void underlyingFileMoved(final IFileBuffer buffer, IPath path) { + // no-op + } + + }); + } + + private static String markerKey(final String message, final int severity, final int charStart, final int charEnd) { + return message + '|' + severity + '|' + charStart + ':' + charEnd; + } + + private static String markerKey(final IMarker marker) throws CoreException { + final var message = String.valueOf(marker.getAttribute(IMarker.MESSAGE)); + final int severity = marker.getAttribute(IMarker.SEVERITY, -1); + final int charStart = marker.getAttribute(IMarker.CHAR_START, -1); + final int charEnd = marker.getAttribute(IMarker.CHAR_END, -1); + return markerKey(message, severity, charStart, charEnd); + } + + private static synchronized void applyMarkers(final IFile file, final List diagnostics) { + try { + final var markdownMarkers = new HashMap(); + for (final IMarker m : file.findMarkers(MARKDOWN_MARKER_TYPE, true, IResource.DEPTH_ZERO)) { + markdownMarkers.putIfAbsent(markerKey(m), m); + } + + for (final Diagnostic d : diagnostics) { + final String msg = d.getMessage(); + final int severity = toIMarkerSeverity(d.getSeverity()); + final int line = d.getRange() != null ? d.getRange().getStart().getLine() + 1 : 1; + int charStart = -1, charEnd = -1; + if (d.getRange() != null) { + try { + final int[] offsets = toOffsets(file, d.getRange()); + charStart = offsets[0]; + charEnd = offsets[1]; + } catch (final Exception ignore) { + ILog.get().warn(ignore.getMessage(), ignore); + } + } + + IMarker target = markdownMarkers.remove(markerKey(msg, severity, charStart, charEnd)); + if (target == null) { + target = file.createMarker(MARKDOWN_MARKER_TYPE); + target.setAttribute(IMarker.MESSAGE, msg); + target.setAttribute(IMarker.SEVERITY, severity); + target.setAttribute(IMarker.LINE_NUMBER, line); + if (charStart >= 0 && charEnd >= 0) { + target.setAttribute(IMarker.CHAR_START, charStart); + target.setAttribute(IMarker.CHAR_END, charEnd); + } else { + target.setAttribute(IMarker.CHAR_START, -1); + target.setAttribute(IMarker.CHAR_END, -1); + } + } + } + + // Delete any of our markers that were not matched this round + for (final IMarker m : markdownMarkers.values()) { + try { + m.delete(); + } catch (Exception ignore) { + ILog.get().warn(ignore.getMessage(), ignore); + } + } + } catch (final Exception ex) { + ILog.get().warn(ex.getMessage(), ex); + } + } + + private static void clearMarkers(final IFile file) throws CoreException { + file.deleteMarkers(MARKDOWN_MARKER_TYPE, true, IResource.DEPTH_ZERO); + } + + private static List extractDiagnostics(final DocumentDiagnosticReport report) { + if (report == null) + return List.of(); + if (report.isLeft()) { + final RelatedFullDocumentDiagnosticReport full = report.getLeft(); + final var out = new ArrayList(); + if (full.getItems() != null) + out.addAll(full.getItems()); + if (full.getRelatedDocuments() != null) { + for (final Either rel : full.getRelatedDocuments() + .values()) { + if (rel != null && rel.isLeft()) { + final FullDocumentDiagnosticReport rfull = rel.getLeft(); + if (rfull.getItems() != null) { + out.addAll(rfull.getItems()); + } + } + // if rel.isRight() -> unchanged for that related doc: ignore + } + } + return out; + } else if (report.isRight()) { + // Unchanged for the main document: do not touch markers + } + return List.of(); + } + + public static void refreshAllOpenMarkdownFiles(final LanguageServer languageServer) { + // Collect .md-like files from open workspace editors would be ideal; for simplicity, scan workspace for *.md, *.markdown, *.mdown + try { + ResourcesPlugin.getWorkspace().getRoot().accept(res -> { + if (res.getType() == IResource.FILE) { + final String name = res.getName().toLowerCase(); + if (name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) { + refreshFile((IFile) res, languageServer); + } + return false; + } + return true; + }); + } catch (final Exception ex) { + ILog.get().warn(ex.getMessage(), ex); + } + } + + public static void refreshFile(final IFile file) { + try { + if (file == null || !file.exists()) + return; + + LanguageServers.forProject(file.getProject()) + .withPreferredServer( + LanguageServersRegistry.getInstance().getDefinition(MarkdownLanguageServer.MARKDOWN_LANGUAGE_SERVER_ID)) + .excludeInactive() + .collectAll((w, ls) -> CompletableFuture.completedFuture(ls)) + .thenAccept(lss -> lss.forEach(ls -> refreshFile(file, ls))); + } catch (final Exception ex) { + ILog.get().warn(ex.getMessage(), ex); + } + } + + private static void refreshFile(final IFile file, final LanguageServer languageServer) { + try { + if (file == null || !file.exists()) + return; + + final String uri = toLspFileUri(file); + final var params = new DocumentDiagnosticParams(); + params.setTextDocument(new TextDocumentIdentifier(uri)); + final DocumentDiagnosticReport report = languageServer.getTextDocumentService().diagnostic(params).get(); + applyMarkers(file, extractDiagnostics(report)); + } catch (final Exception ex) { + ILog.get().warn(ex.getMessage(), ex); + } + } + + private static int toIMarkerSeverity(final DiagnosticSeverity sev) { + if (sev == null) + return IMarker.SEVERITY_INFO; + return switch (sev) { + case Error -> IMarker.SEVERITY_ERROR; + case Warning -> IMarker.SEVERITY_WARNING; + case Information, Hint -> IMarker.SEVERITY_INFO; + }; + } + + private static String toLspFileUri(final IFile file) { + String s = file.getLocationURI().toString(); + // Normalize Windows drive URIs to file:///C:/... + if (s.startsWith("file:/") && !s.startsWith("file:///") && s.length() >= 8 && Character.isLetter(s.charAt(6)) + && s.charAt(7) == ':') { + return "file:///" + s.substring("file:/".length()); + } + return s; + } + + private static int[] toOffsets(final IFile file, final Range range) throws CoreException, BadLocationException { + // Connect ensures a document is available even if no editor is open + final var mgr = FileBuffers.getTextFileBufferManager(); + final var path = file.getFullPath(); + mgr.connect(path, LocationKind.IFILE, null); + try { + final var buf = mgr.getTextFileBuffer(path, LocationKind.IFILE); + final IDocument doc = buf != null ? buf.getDocument() : null; + if (doc == null) { + return new int[] { 0, 0 }; + } + final int startLine = Math.max(0, range.getStart().getLine()); + final int startCol = Math.max(0, range.getStart().getCharacter()); + final int endLine = Math.max(0, range.getEnd().getLine()); + final int endCol = Math.max(0, range.getEnd().getCharacter()); + int start = Math.min(doc.getLength(), doc.getLineOffset(startLine) + startCol); + int end = Math.min(doc.getLength(), doc.getLineOffset(endLine) + endCol); + if (end < start) + end = start; + return new int[] { start, end }; + } finally { + mgr.disconnect(path, LocationKind.IFILE, null); + } + } + + private MarkdownDiagnosticsManager() { + } +} diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java new file mode 100644 index 0000000000..0a5e17d07d --- /dev/null +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java @@ -0,0 +1,442 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.wildwebdeveloper.markdown; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IResourceChangeEvent; +import org.eclipse.core.resources.IResourceChangeListener; +import org.eclipse.core.resources.IResourceDelta; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.FileLocator; +import org.eclipse.core.runtime.ILog; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.client.DefaultLanguageClient; +import org.eclipse.lsp4j.ConfigurationItem; +import org.eclipse.lsp4j.ConfigurationParams; +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest; +import org.eclipse.wildwebdeveloper.embedder.node.NodeJSManager; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences; +import org.eclipse.wildwebdeveloper.ui.preferences.Settings; +import org.eclipse.wildwebdeveloper.util.FileUtils; + +import com.google.gson.Gson; + +/** + * LSP client-side handlers for vscode-markdown-languageserver custom requests. + * + * See https://github.com/microsoft/vscode-markdown-languageserver#custom-requests + */ +public final class MarkdownLanguageClient extends DefaultLanguageClient { + + private static Path resolveResource(final String resourcePath) throws IOException { + try { + final URL url = FileLocator.toFileURL(MarkdownLanguageClient.class.getResource(resourcePath)); + return Paths.get(url.toURI()).toAbsolutePath(); + } catch (URISyntaxException ex) { + throw new IOException("Failed to resolve resource URI: " + resourcePath, ex); + } + } + + private static final class Watcher implements IResourceChangeListener { + final int id; + final Path watchRoot; + final boolean ignoreCreate; + final boolean ignoreChange; + final boolean ignoreDelete; + final MarkdownLanguageServerAPI server; + + Watcher(final MarkdownLanguageServerAPI server, final int id, Path watchRoot, + final boolean ignoreCreate, final boolean ignoreChange, final boolean ignoreDelete) { + this.server = server; + this.id = id; + this.watchRoot = watchRoot; + this.ignoreCreate = ignoreCreate; + this.ignoreChange = ignoreChange; + this.ignoreDelete = ignoreDelete; + } + + @Override + public void resourceChanged(final IResourceChangeEvent event) { + final IResourceDelta delta = event.getDelta(); + if (delta == null) + return; + try { + delta.accept(deltaNode -> { // visit every changed node in this delta tree + final IResource res = deltaNode.getResource(); + + // only care about file-level changes + if (res == null || res.getType() != IResource.FILE) + return true; + + final URI uri = res.getLocationURI(); + if (uri == null) + return true; + + // Constrain events to the configured watcher baseUri. If resolution fails + // or the path is outside the base, skip this node. + if (watchRoot != null) { + try { + if (!Paths.get(uri).startsWith(watchRoot)) + return true; // outside watched root + } catch (final Exception ex) { + return true; // resolution error: ignore + } + } + + // Map delta kind to watcher kind, honoring ignore* options. + final String kind; + switch (deltaNode.getKind()) { + case IResourceDelta.ADDED: + if (ignoreCreate) + return true; + kind = "create"; + break; + case IResourceDelta.REMOVED: + if (ignoreDelete) + return true; + kind = "delete"; + break; + case IResourceDelta.CHANGED: + if (ignoreChange) + return true; + // Only content changes; skip metadata-only changes + if ((deltaNode.getFlags() & IResourceDelta.CONTENT) == 0) + return true; + kind = "change"; + break; + default: + return true; + } + + // Notify server of the file system event for this resource + final var payload = new HashMap(); + payload.put("id", Integer.valueOf(id)); + payload.put("uri", uri.toString()); + payload.put("kind", kind); + server.fsWatcherOnChange(payload); + return true; + }); + } catch (final Exception ex) { + ILog.get().warn(ex.getMessage(), ex); + } + } + } + + private static volatile String mdParseHelperPath; + + private final Map watchersById = new ConcurrentHashMap<>(); + + public MarkdownLanguageClient() throws IOException { + if (mdParseHelperPath == null) { + mdParseHelperPath = resolveResource("md-parse.js").toString(); + } + } + + @Override + public CompletableFuture> configuration(final ConfigurationParams params) { + return CompletableFuture.supplyAsync(() -> { + final var results = new ArrayList<>(); + for (final ConfigurationItem item : params.getItems()) { + final String section = item.getSection(); + if (MarkdownPreferences.isMatchMarkdownSection(section)) { + final Settings md = MarkdownPreferences.getGlobalSettings(); + results.add(md.findSettings(section.split("[.]"))); + } else { + results.add(null); + } + } + return results; + }); + } + + // Acknowledge diagnostic refresh requests and trigger a diagnostic pull + @Override + public CompletableFuture refreshDiagnostics() { + return CompletableFuture.runAsync(() -> MarkdownDiagnosticsManager.refreshAllOpenMarkdownFiles(getLanguageServer())); + } + + /** + *
+	 * markdown/parse
+	 * Request: { uri: string; text?: string }
+	 * Response: md.Token[] (serialized markdown-it token objects)
+	 * 
+ */ + @JsonRequest("markdown/parse") + public CompletableFuture>> parseMarkdown(final Map params) { + return CompletableFuture.supplyAsync(() -> { + Path tmp = null; + try { + String text = null; + final boolean hasText = params != null && params.get("text") instanceof String; + if (hasText) { + text = (String) params.get("text"); + } + Path uriPath = null; + if (params != null) { + final var uri = params.get("uri"); + if (uri != null) { + final Path p = FileUtils.uriToPath(uri.toString()); + if (Files.exists(p)) { + uriPath = p; + } + } + } + if (!hasText && uriPath == null) { + return Collections.emptyList(); + } + final String argPath; + if (hasText) { + tmp = Files.createTempFile("wwd-md-", ".md"); + Files.writeString(tmp, text, StandardCharsets.UTF_8); + argPath = tmp.toAbsolutePath().toString(); + } else { + argPath = uriPath.toAbsolutePath().toString(); + } + final var pb = new ProcessBuilder(List.of(NodeJSManager.getNodeJsLocation().getAbsolutePath(), mdParseHelperPath, argPath)); + final Process proc = pb.start(); + final var out = new String(proc.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + final int exit = proc.waitFor(); + if (exit != 0) { + final var err = new String(proc.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + ILog.get().warn("markdown-it parser failed (" + exit + "): " + err, null); + return Collections.emptyList(); + } + final var gson = new Gson(); + final List> tokens = gson.fromJson(out, List.class); + // Opportunistically trigger a diagnostic pull for this document + try { + final var uri = URI.create((String) params.get("uri")); + final var res = LSPEclipseUtils.findResourceFor(uri); + if (res instanceof final IFile file) { + CompletableFuture.runAsync(() -> MarkdownDiagnosticsManager.refreshFile(file)); + } + } catch (final Exception ignore) { + } + return tokens != null ? tokens : Collections.emptyList(); + } catch (final InterruptedException ex) { + ILog.get().warn(ex.getMessage(), ex); + /* Clean up whatever needs to be handled before interrupting */ + Thread.currentThread().interrupt(); + } catch (final Exception ex) { + ILog.get().warn(ex.getMessage(), ex); + } finally { + if (tmp != null) + try { + Files.deleteIfExists(tmp); + } catch (final Exception ignore) { + } + } + return Collections.emptyList(); + }); + } + + /** + *
+	 * Request: {}
+	 * Response: string[] (URIs of Markdown files in the workspace)
+	 * 
+ */ + @JsonRequest("markdown/findMarkdownFilesInWorkspace") + public CompletableFuture> findMarkdownFilesInWorkspace(final Object unused) { + return CompletableFuture.supplyAsync(() -> { + final var uris = new ArrayList(); + try { + final var roots = MarkdownLanguageServer.getServerRoots(); + if (roots != null && !roots.isEmpty()) { + for (String rootUri : roots) { + final var containers = ResourcesPlugin.getWorkspace().getRoot() + .findContainersForLocationURI(new java.net.URI(rootUri)); + if (containers != null && containers.length > 0) { + for (final var container : containers) { + container.accept((final IResource res) -> { + if (res.getType() == IResource.FILE) { + final String name = res.getName().toLowerCase(); + if (name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) { + uris.add(res.getLocationURI().toString()); + } + return false; // no children + } + return true; // continue + }); + } + } + } + } else { + // Fallback: scan entire workspace + final IWorkspaceRoot wsRoot = ResourcesPlugin.getWorkspace().getRoot(); + wsRoot.accept((final IResource res) -> { + if (res.getType() == IResource.FILE) { + final String name = res.getName().toLowerCase(); + if (name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) { + uris.add(res.getLocationURI().toString()); + } + return false; // no children + } + return true; // continue + }); + } + } catch (final Exception ex) { + ILog.get().warn(ex.getMessage(), ex); + } + return uris; + }); + } + + /** + *
+	 * Request: { uri: string }
+	 * Response: [string, { isDirectory: boolean }][]
+	 * 
+ */ + @JsonRequest("markdown/fs/readDirectory") + public CompletableFuture>> fsReadDirectory(final Map params) { + return CompletableFuture.supplyAsync(() -> { + try { + final var uri = params.get("uri"); + if (uri == null) + return List.of(); + + return Files.list(FileUtils.uriToPath(uri.toString())) // + .map(child -> List.of( // + child.getFileName().toString(), // + Map.of("isDirectory", Files.isDirectory(child)))) // + .toList(); + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } + }); + } + + /** + *
+	 * Request: { uri: string }
+	 * Response: number[] (file bytes 0-255)
+	 * 
+ */ + @JsonRequest("markdown/fs/readFile") + public CompletableFuture> fsReadFile(final Map params) { + return CompletableFuture.supplyAsync(() -> { + try { + final var uri = params.get("uri"); + if (uri == null) + return List.of(); + + final var path = FileUtils.uriToPath(uri.toString()); + final byte[] bytes = Files.readAllBytes(path); + final var out = new ArrayList(bytes.length); + for (final byte b : bytes) { + out.add(Byte.toUnsignedInt(b)); + } + return out; + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } + }); + } + + /** + *
+	 * Request: { uri: string }
+	 * Response: { isDirectory: boolean } | undefined (null here represents undefined)
+	 * 
+ */ + @JsonRequest("markdown/fs/stat") + public CompletableFuture> fsStat(final Map params) { + return CompletableFuture.supplyAsync(() -> { + final var uri = params.get("uri"); + if (uri == null) + return null; + final var file = FileUtils.uriToFile(uri.toString()); + if (!file.exists()) + return null; + final var result = new HashMap(1, 1f); + result.put("isDirectory", Boolean.valueOf(file.isDirectory())); + return result; + }); + } + + /** + *
+	 * Request: {
+	 *   id: number;
+	 *   uri: string; // file: URI of file or directory to watch
+	 *   options: { ignoreCreate?: boolean, ignoreChange?: boolean, ignoreDelete?: boolean };
+	 *   watchParentDirs: boolean;
+	 * }
+	 * Response: void
+	 * 
+ */ + @JsonRequest("markdown/fs/watcher/create") + public CompletableFuture fsWatcherCreate(final Map params) { + return CompletableFuture.supplyAsync(() -> { + final int id = ((Number) params.get("id")).intValue(); + + final var uri = params.get("uri"); + if (uri == null) + return null; + final var path = uri == null ? null : FileUtils.uriToPath(uri.toString()); + + @SuppressWarnings("unchecked") + final Map options = params.get("options") instanceof Map // + ? (Map) params.get("options") + : Collections.emptyMap(); + final var watcher = new Watcher((MarkdownLanguageServerAPI) getLanguageServer(), id, path, + Boolean.TRUE.equals(options.get("ignoreCreate")), // + Boolean.TRUE.equals(options.get("ignoreChange")), + Boolean.TRUE.equals(options.get("ignoreDelete"))); + ResourcesPlugin.getWorkspace().addResourceChangeListener(watcher, IResourceChangeEvent.POST_CHANGE); + watchersById.put(Integer.valueOf(id), watcher); + return null; + }); + } + + /** + *
+	 * Request: { id: number }
+	 * Response: void
+	 * 
+ */ + @JsonRequest("markdown/fs/watcher/delete") + public CompletableFuture fsWatcherDelete(final Map params) { + return CompletableFuture.supplyAsync(() -> { + final Object idObj = params != null ? params.get("id") : null; + if (idObj instanceof final Number id) { + final Watcher watcher = watchersById.remove(id.intValue()); + if (watcher != null) { + ResourcesPlugin.getWorkspace().removeResourceChangeListener(watcher); + } + } + return null; + }); + } +} diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageServer.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageServer.java new file mode 100644 index 0000000000..fa3a8354e6 --- /dev/null +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageServer.java @@ -0,0 +1,136 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.wildwebdeveloper.markdown; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.eclipse.core.runtime.FileLocator; +import org.eclipse.lsp4j.DidChangeConfigurationParams; +import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.jsonrpc.messages.Message; +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage; +import org.eclipse.lsp4j.services.LanguageServer; +import org.eclipse.wildwebdeveloper.Activator; +import org.eclipse.wildwebdeveloper.embedder.node.NodeJSManager; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences; +import org.eclipse.wildwebdeveloper.ui.preferences.ProcessStreamConnectionProviderWithPreference; + +/** + * Launches the embedded Node.js based Markdown language server. + * + * See https://github.com/microsoft/vscode-markdown-languageservice + * + * @author Sebastian Thomschke + */ +public final class MarkdownLanguageServer extends ProcessStreamConnectionProviderWithPreference { + + static final String MARKDOWN_LANGUAGE_SERVER_ID = "org.eclipse.wildwebdeveloper.markdown"; + + // If/when Markdown preferences are added, list their root sections here + private static final String[] SUPPORTED_SECTIONS = { "markdown" }; + + private static volatile String markdownLanguageServerPath; + private static volatile String proxyPath; + + // Track roots with ref-counts to avoid leaks when servers stop + private static final ConcurrentHashMap SERVER_ROOT_COUNTS = new ConcurrentHashMap<>(); + private String instanceRootUri; + + public static Set getServerRoots() { + return Collections.unmodifiableSet(SERVER_ROOT_COUNTS.keySet()); + } + + private static Path resolveResource(String resourcePath) throws IOException { + try { + URL url = FileLocator.toFileURL(MarkdownLanguageServer.class.getResource(resourcePath)); + return Paths.get(url.toURI()).toAbsolutePath(); + } catch (URISyntaxException ex) { + throw new IOException("Failed to resolve resource URI: " + resourcePath, ex); + } + } + + public MarkdownLanguageServer() throws IOException { + super(MARKDOWN_LANGUAGE_SERVER_ID, Activator.getDefault().getPreferenceStore(), SUPPORTED_SECTIONS); + + if (markdownLanguageServerPath == null) { + markdownLanguageServerPath = resolveResource("/node_modules/vscode-markdown-languageserver/dist/node/workerMain.js").toString(); + } + if (proxyPath == null) { + proxyPath = resolveResource("md-lsp-proxy.js").toString(); + } + setCommands(List.of( + NodeJSManager.getNodeJsLocation().getAbsolutePath(), + proxyPath, + markdownLanguageServerPath, + "--stdio")); + setWorkingDirectory(System.getProperty("user.dir")); + } + + @Override + public Map getInitializationOptions(final URI projectRootUri) { + final Map options = new HashMap<>(); + + if (projectRootUri != null) { + setWorkingDirectory(projectRootUri.getRawPath()); + + // Remember this root for scoping client-side workspace queries + instanceRootUri = projectRootUri.toString(); + SERVER_ROOT_COUNTS.compute(instanceRootUri, (k, v) -> { + if (v == null) + return new AtomicInteger(1); + v.incrementAndGet(); + return v; + }); + } + + // https://github.com/microsoft/vscode-markdown-languageserver#initialization-options + options.put("markdownFileExtensions", List.of("md", "markdown", "mdown")); + return options; + } + + @Override + protected Object createSettings() { + return MarkdownPreferences.getGlobalSettings(); + } + + @Override + public void stop() { + if (instanceRootUri != null) { + SERVER_ROOT_COUNTS.computeIfPresent(instanceRootUri, (k, v) -> v.decrementAndGet() <= 0 ? null : v); + instanceRootUri = null; + } + super.stop(); + } + + @Override + public void handleMessage(final Message message, final LanguageServer languageServer, final URI rootUri) { + if (message instanceof final ResponseMessage response) { + if (response.getResult() instanceof InitializeResult) { + final var params = new DidChangeConfigurationParams(createSettings()); + languageServer.getWorkspaceService().didChangeConfiguration(params); + } + } + } +} diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageServerAPI.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageServerAPI.java new file mode 100644 index 0000000000..a81dda1f2e --- /dev/null +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageServerAPI.java @@ -0,0 +1,34 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.wildwebdeveloper.markdown; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest; +import org.eclipse.lsp4j.services.LanguageServer; + +/** + * Markdown language server API for client->server custom requests. + */ +public interface MarkdownLanguageServerAPI extends LanguageServer { + + /** + *
+	 * Request: { id: number; uri: string; kind: 'create' | 'change' | 'delete' }
+	 * Response: void
+	 * 
+ */ + @JsonRequest("markdown/fs/watcher/onChange") + CompletableFuture fsWatcherOnChange(Map params); +} diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/md-lsp-proxy.js b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/md-lsp-proxy.js new file mode 100644 index 0000000000..2de64f377b --- /dev/null +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/md-lsp-proxy.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ + +// This is an LSP stdio proxy to intercept and manipulate the communication between the LSP and the client. +// Why this is needed: +// 1) Invalid server message (server→client): +// Problem: The Markdown server sometimes sends `window/logMessage` with a non-string `params.message`, +// violating the LSP (which requires a string). See also +// https://github.com/microsoft/vscode-markdown-languageserver/issues/8 +// Fix: Sanitize these messages by stringifying `params.message` so the client accepts them. +// +// 2) Windows URI normalization mismatch (client→server) breaking pull diagnostics: +// Problem: LSP4E emits URIs like `file:///D:/...`, while the server keys open documents by +// `vscode-uri`'s `URI.toString()` form, which on Windows is `file:///d%3A/...` (lowercased +// drive letter and percent-encoded colon). Because the strings differ, the server does not +// recognize the document as open (`workspace.hasMarkdownDocument(uri)` fails) and returns +// empty diagnostics. +// Fix: For text document lifecycle notifications (didOpen/didChange/didClose/willSave/didSave), +// forward the original message and also a duplicate where the URI is normalized to the +// server's `URI.toString()` form. This guarantees the server tracks the open document under +// the key it uses during diagnostics, enabling `computeDiagnostics()` and `markdown/fs/*` requests. +// +// Note: On non-Windows URIs, normalization is a no-op; messages are forwarded unchanged. + +const { spawn } = require('child_process'); + +if (process.argv.length < 3) { + console.error('Usage: md-lsp-proxy.js [args...]'); + process.exit(1); +} + +const serverMain = process.argv[2]; +const serverArgs = process.argv.slice(3); +// Launch the wrapped language server; we proxy its stdio +const child = spawn(process.execPath, [serverMain, ...serverArgs], { stdio: ['pipe', 'pipe', 'inherit'] }); + +// Client → Server (Problem 2): normalize Windows URIs and mirror lifecycle notifications +let inBuffer = Buffer.alloc(0); +process.stdin.on('data', chunk => { + inBuffer = Buffer.concat([inBuffer, chunk]); + processInboundBuffer(); +}); + +// Server → Client (Problem 1): sanitize window/logMessage and forward +let buffer = Buffer.alloc(0); +child.stdout.on('data', chunk => { + buffer = Buffer.concat([buffer, chunk]); + processBuffer(); +}); + +child.on('exit', (code, _signal) => { + // forward EOF + try { process.stdout.end(); } catch { } + process.exitCode = code ?? 0; +}); + +// Server → Client processing (Problem 1) +function processBuffer() { + for (; ;) { + const headerEnd = indexOfHeadersEnd(buffer); + if (headerEnd === -1) return; // need more data + + const headerBytes = buffer.slice(0, headerEnd); + const headers = headerBytes.toString('utf8'); + const contentLength = parseContentLength(headers); + if (contentLength == null) { + // Cannot parse content length, flush raw and reset + process.stdout.write(buffer); + buffer = Buffer.alloc(0); + return; + } + const total = headerEnd + 4 + contentLength; + if (buffer.length < total) return; // wait for full body + + const body = buffer.slice(headerEnd + 4, total); + const sanitized = sanitizeBody(body); + const outHeaders = buildHeaders(sanitized.length); + process.stdout.write(outHeaders); + process.stdout.write(sanitized); + + buffer = buffer.slice(total); + } +} + +// Client → Server processing (Problem 2) +function processInboundBuffer() { + for (; ;) { + const headerEnd = indexOfHeadersEnd(inBuffer); + if (headerEnd === -1) return; + + const headerBytes = inBuffer.slice(0, headerEnd); + const headers = headerBytes.toString('utf8'); + const contentLength = parseContentLength(headers); + if (contentLength == null) { + // Cannot parse content length, flush raw and reset + child.stdin.write(inBuffer); + inBuffer = Buffer.alloc(0); + return; + } + const total = headerEnd + 4 + contentLength; + if (inBuffer.length < total) return; + + const body = inBuffer.slice(headerEnd + 4, total); + const outbound = transformInbound(body); + for (const msgBuf of outbound) { + const outHeaders = buildHeaders(msgBuf.length); + child.stdin.write(outHeaders); + child.stdin.write(msgBuf); + } + + inBuffer = inBuffer.slice(total); + } +} + +// Duplicate lifecycle notifications with a normalized URI so diagnostics can run (Problem 2) +function transformInbound(bodyBuf) { + try { + const text = bodyBuf.toString('utf8'); + const msg = JSON.parse(text); + const method = msg && msg.method; + + // For text document lifecycle events, also send a duplicate event + // with a normalized file URI so the server's URI.toString() lookups match. + if ((method === 'textDocument/didOpen' || method === 'textDocument/didChange' || method === 'textDocument/didClose' || method === 'textDocument/willSave' || method === 'textDocument/didSave') && msg.params && msg.params.textDocument) { + const origUri = msg.params.textDocument.uri; + const normUri = normalizeFileUriForServer(origUri); + if (normUri && normUri !== origUri) { + const dup = structuredClone(msg); + dup.params.textDocument.uri = normUri; + // Send original first (exact client payload), then the normalized duplicate (server-friendly) + return [Buffer.from(JSON.stringify(msg), 'utf8'), Buffer.from(JSON.stringify(dup), 'utf8')]; + } + } + } catch { } + return [bodyBuf]; +} + +// Normalize Windows file URIs to match vscode-uri’s URI.toString() (Problem 2) +function normalizeFileUriForServer(uri) { + if (typeof uri !== 'string' || !uri.startsWith('file:///')) + return undefined; + + // Normalize Windows drive letter and encode colon to match vscode-uri toString() + // Example: file:///D:/path -> file:///d%3A/path + const after = uri.slice('file:///'.length); + if (/^[A-Za-z]:/.test(after)) { + const drive = after[0].toLowerCase(); + const rest = after.slice(2); // drop ":" + return 'file:///' + drive + '%3A' + rest; + } + return undefined; +} + +function indexOfHeadersEnd(buf) { + // search for \r\n\r\n + for (let i = 0; i + 3 < buf.length; i++) { + if (buf[i] === 13 && buf[i + 1] === 10 && buf[i + 2] === 13 && buf[i + 3] === 10) return i; + } + return -1; +} + +function parseContentLength(headers) { + const match = /Content-Length:\s*(\d+)/i.exec(headers); + if (!match) return null; + return Number.parseInt(match[1], 10); +} + +function buildHeaders(length) { + return Buffer.from(`Content-Length: ${length}\r\n\r\n`, 'utf8'); +} + +// Sanitize server log messages (Problem 1) +function sanitizeBody(bodyBuf) { + try { + const text = bodyBuf.toString('utf8'); + const msg = JSON.parse(text); + if (msg && msg.method === 'window/logMessage' && msg.params && typeof msg.params.message !== 'string') { + msg.params.message = JSON.stringify(msg.params.message); + return Buffer.from(JSON.stringify(msg), 'utf8'); + } + } catch { + } + return bodyBuf; +} diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/md-parse.js b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/md-parse.js new file mode 100644 index 0000000000..ffa75d7387 --- /dev/null +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/md-parse.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ + +// Simple Markdown-it parser for LSP client `markdown/parse`. +// Accepts either a file path argument or reads from stdin and prints JSON array of tokens. + +const fs = require('node:fs'); + +function loadMarkdownIt() { + try { + return require('markdown-it'); + } catch (e) { + console.error('markdown-it is not installed. Please add it to dependencies.'); + process.exit(2); + } +} + +const MarkdownIt = loadMarkdownIt(); +const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: false, +}); + +function tokenToJSON(tok) { + const out = { + type: tok.type, + tag: tok.tag, + attrs: tok.attrs || null, + map: tok.map || null, + nesting: tok.nesting, + level: tok.level, + content: tok.content, + markup: tok.markup, + info: tok.info, + meta: tok.meta || null, + block: tok.block, + hidden: tok.hidden, + }; + if (tok.children && Array.isArray(tok.children)) { + out.children = tok.children.map(tokenToJSON); + } + return out; +} + +const argPath = process.argv[2]; +if (argPath) { + try { + const input = fs.readFileSync(argPath, 'utf8'); + const env = {}; + const tokens = md.parse(input, env); + const json = tokens.map(tokenToJSON); + process.stdout.write(JSON.stringify(json)); + } catch (err) { + console.error(String(err && err.stack ? err.stack : err)); + process.exit(1); + } +} else { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => (input += chunk)); + process.stdin.on('end', () => { + try { + const env = {}; + const tokens = md.parse(input, env); + const json = tokens.map(tokenToJSON); + process.stdout.write(JSON.stringify(json)); + } catch (err) { + console.error(String(err && err.stack ? err.stack : err)); + process.exit(1); + } + }); +} + diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferenceInitializer.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferenceInitializer.java new file mode 100644 index 0000000000..505080f55b --- /dev/null +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferenceInitializer.java @@ -0,0 +1,57 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.wildwebdeveloper.markdown.ui.preferences; + +import static org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.*; + +import org.eclipse.core.runtime.preferences.AbstractPreferenceInitializer; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.wildwebdeveloper.Activator; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.IncludeWorkspaceHeaderCompletions; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.PreferredMdPathExtensionStyle; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.ServerLog; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.ValidateEnabled; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.ValidateEnabledForFragmentLinks; + +/** + * Initializes default Markdown preferences. + */ +public final class MarkdownPreferenceInitializer extends AbstractPreferenceInitializer { + + @Override + public void initializeDefaultPreferences() { + final IPreferenceStore store = Activator.getDefault().getPreferenceStore(); + + store.setDefault(MD_SERVER_LOG, ServerLog.off.value); + store.setDefault(MD_OCCURRENCES_HIGHLIGHT_ENABLED, true); + + /* + * Suggest + */ + store.setDefault(MD_PREFERRED_MD_PATH_EXTENSION_STYLE, PreferredMdPathExtensionStyle.auto.value); + store.setDefault(MD_SUGGEST_PATHS_ENABLED, true); + store.setDefault(MD_SUGGEST_PATHS_INCLUDE_WKS_HEADER_COMPLETIONS, IncludeWorkspaceHeaderCompletions.onDoubleHash.value); + + /* + * Validation + */ + store.setDefault(MD_VALIDATE_ENABLED, true); + store.setDefault(MD_VALIDATE_REFERENCE_LINKS_ENABLED, ValidateEnabled.warning.value); + store.setDefault(MD_VALIDATE_FRAGMENT_LINKS_ENABLED, ValidateEnabled.warning.value); + store.setDefault(MD_VALIDATE_FILE_LINKS_ENABLED, ValidateEnabled.warning.value); + store.setDefault(MD_VALIDATE_FILE_LINKS_MARKDOWN_FRAGMENT_LINKS, ValidateEnabledForFragmentLinks.inherit.value); + store.setDefault(MD_VALIDATE_IGNORED_LINKS, ""); + store.setDefault(MD_VALIDATE_UNUSED_LINK_DEFS_ENABLED, ValidateEnabled.warning.value); + store.setDefault(MD_VALIDATE_DUPLICATE_LINK_DEFS_ENABLED, ValidateEnabled.error.value); + } +} diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferencePage.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferencePage.java new file mode 100644 index 0000000000..e9ddd03aa2 --- /dev/null +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferencePage.java @@ -0,0 +1,259 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.wildwebdeveloper.markdown.ui.preferences; + +import static org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.*; + +import java.lang.reflect.Field; +import java.util.Arrays; + +import org.eclipse.jface.layout.GridDataFactory; +import org.eclipse.jface.layout.GridLayoutFactory; +import org.eclipse.jface.preference.BooleanFieldEditor; +import org.eclipse.jface.preference.ComboFieldEditor; +import org.eclipse.jface.preference.FieldEditorPreferencePage; +import org.eclipse.jface.preference.StringFieldEditor; +import org.eclipse.jface.util.PropertyChangeEvent; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Group; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPreferencePage; +import org.eclipse.wildwebdeveloper.Activator; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.IncludeWorkspaceHeaderCompletions; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.PreferredMdPathExtensionStyle; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.ServerLog; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.ValidateEnabled; +import org.eclipse.wildwebdeveloper.markdown.ui.preferences.MarkdownPreferences.ValidateEnabledForFragmentLinks; + +/** + * Markdown main preference page. + */ +public final class MarkdownPreferencePage extends FieldEditorPreferencePage implements IWorkbenchPreferencePage { + + public MarkdownPreferencePage() { + super(GRID); + } + + @Override + public void init(final IWorkbench workbench) { + setPreferenceStore(Activator.getDefault().getPreferenceStore()); + } + + private static > String[][] toLabelValueArray(final Class enumClass) { + try { + final Field labelField = enumClass.getDeclaredField("label"); + final Field valueField = enumClass.getDeclaredField("value"); + return Arrays.stream(enumClass.getEnumConstants()) + .map(enumValue -> { + try { + return new String[] { // + (String) labelField.get(enumValue), // + (String) valueField.get(enumValue) // + }; + } catch (final IllegalAccessException ex) { + throw new RuntimeException(ex); + } + }) + .toArray(String[][]::new); + } catch (final NoSuchFieldException ex) { + throw new IllegalArgumentException(enumClass.getName() + " must have 'label' and 'value' fields"); + } + } + + private BooleanFieldEditor validateEnabledEditor; + private Composite validationGroup; + + @Override + protected void createFieldEditors() { + final Composite pageParent = getFieldEditorParent(); + // General + addField(new ComboFieldEditor( + MD_SERVER_LOG, + "Server log level", + toLabelValueArray(ServerLog.class), + pageParent)); + + // Occurrences + addField(new BooleanFieldEditor(MD_OCCURRENCES_HIGHLIGHT_ENABLED, "Highlight link occurrences", pageParent)); + + // Suggestions + final var suggestionsGroup = createPaddedGroup(pageParent, "Suggestions"); + addField(new ComboFieldEditor( + MD_PREFERRED_MD_PATH_EXTENSION_STYLE, + "Path suggestions: add file extensions (e.g. `.md`) for links to Markdown files?", + toLabelValueArray(PreferredMdPathExtensionStyle.class), + suggestionsGroup)); + addField(new BooleanFieldEditor(MD_SUGGEST_PATHS_ENABLED, "Enable path suggestions while writing links", + suggestionsGroup)); + addField(new ComboFieldEditor( + MD_SUGGEST_PATHS_INCLUDE_WKS_HEADER_COMPLETIONS, + "Enable suggestions for headers in other Markdown files", + toLabelValueArray(IncludeWorkspaceHeaderCompletions.class), + suggestionsGroup)); + + // Validation + validateEnabledEditor = new BooleanFieldEditor(MD_VALIDATE_ENABLED, "Enable validation", pageParent); + addField(validateEnabledEditor); + + validationGroup = createPaddedGroup(pageParent, "Validation settings"); + addField(new ComboFieldEditor( + MD_VALIDATE_REFERENCE_LINKS_ENABLED, + "Reference links validation severity [text][ref]", + toLabelValueArray(ValidateEnabled.class), + validationGroup)); + addField(new ComboFieldEditor( + MD_VALIDATE_FRAGMENT_LINKS_ENABLED, + "Fragment links validation severity [text](#head)", + toLabelValueArray(ValidateEnabled.class), + validationGroup)); + addField(new ComboFieldEditor( + MD_VALIDATE_FILE_LINKS_ENABLED, + "File links validation severity", + toLabelValueArray(ValidateEnabled.class), + validationGroup)); + addField(new ComboFieldEditor( + MD_VALIDATE_FILE_LINKS_MARKDOWN_FRAGMENT_LINKS, + "Fragment part of links to headers in other files in Markdown files", + toLabelValueArray(ValidateEnabledForFragmentLinks.class), + validationGroup)); + + final var ignoredLinks = new StringFieldEditor( + MD_VALIDATE_IGNORED_LINKS, + "Ignored link globs (comma-separated)", + validationGroup); + addField(ignoredLinks); + final String ignoredTooltip = """ + Glob patterns are matched against the link destination (href). + + Suppresses: + • Missing file -> match the path only + e.g. docs/generated/**, **/images/** + • Missing header (same file) -> match '#fragment' + e.g. #*, #intro + • Missing header (other file) -> match 'path#fragment' or just the path + e.g. /guide.md#*, docs/guide.md#intro, /guide.md + """; + ignoredLinks.getLabelControl(validationGroup).setToolTipText(ignoredTooltip); + ignoredLinks.getTextControl(validationGroup).setToolTipText(ignoredTooltip); + + addField(new ComboFieldEditor( + MD_VALIDATE_UNUSED_LINK_DEFS_ENABLED, + "Unused link definitions severity", + toLabelValueArray(ValidateEnabled.class), + validationGroup)); + addField(new ComboFieldEditor( + MD_VALIDATE_DUPLICATE_LINK_DEFS_ENABLED, + "Duplicated definitions severity", + toLabelValueArray(ValidateEnabled.class), + validationGroup)); + } + + @Override + protected void initialize() { + super.initialize(); + // Ensure enablement matches loaded preference values after editors are initialized + updateValidationGroupEnablement(); + normalizeComboWidths(getFieldEditorParent()); + } + + @Override + public void propertyChange(final PropertyChangeEvent event) { + super.propertyChange(event); + if (event.getSource() == validateEnabledEditor) { + updateValidationGroupEnablement(); + } + } + + private void updateValidationGroupEnablement() { + final boolean enabled = validateEnabledEditor != null && validateEnabledEditor.getBooleanValue(); + setEnabledRecursive(validationGroup, enabled); + } + + private static void setEnabledRecursive(final Control control, final boolean enabled) { + if (control == null || control.isDisposed()) + return; + + control.setEnabled(enabled); + if (control instanceof final Composite comp) { + for (final Control child : comp.getChildren()) { + setEnabledRecursive(child, enabled); + } + } + } + + public static Composite createPaddedGroup(final Composite parent, final String title) { + final int marginWidth = 12; + final int marginHeight = 8; + final int spacingX = 8; + final int spacingY = 6; + final var group = new Group(parent, SWT.NONE); + if (title != null) + group.setText(title); + + group.setLayoutData(GridDataFactory.fillDefaults() + .grab(true, false) + .span(2 /* Field editors use 2-column grids */, 0) + .create()); + + group.setLayout(new GridLayout(1, false)); + + final var body = new Composite(group, SWT.NONE); + body.setLayoutData(GridDataFactory.fillDefaults() + .grab(true, false) + .create()); + + body.setLayout(GridLayoutFactory.swtDefaults() + .numColumns(2 /* Field editors use 2-column grids */) + .margins(marginWidth, marginHeight) + .spacing(spacingX, spacingY) + .create()); + + return body; + } + + private void normalizeComboWidths(final Composite container) { + if (container == null || container.isDisposed()) + return; + + final var gc = new GC(container); + try { + for (final Control child : container.getChildren()) { + if (child instanceof final Composite comp) { + normalizeComboWidths(comp); + } + if (child instanceof final Combo combo) { + final var gd = new GridData(SWT.BEGINNING, SWT.CENTER, false, false); + + int max = 0; + for (final String item : combo.getItems()) { + final Point p = gc.textExtent(item); + if (p.x > max) + max = p.x; + } + gd.widthHint = max; + child.setLayoutData(gd); + } + } + } finally { + gc.dispose(); + } + container.layout(true, true); + } +} diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferences.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferences.java new file mode 100644 index 0000000000..b56bdd995b --- /dev/null +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferences.java @@ -0,0 +1,175 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.wildwebdeveloper.markdown.ui.preferences; + +import static org.eclipse.wildwebdeveloper.ui.preferences.Settings.isMatchSection; + +import org.eclipse.jface.preference.IPreferenceStore; +import java.util.Arrays; +import org.eclipse.wildwebdeveloper.Activator; +import org.eclipse.wildwebdeveloper.ui.preferences.Settings; + +/** + * Markdown preference server constants and helpers. + * + * See https://github.com/microsoft/vscode-markdown-languageserver/blob/c827107fea3b3252c9f769caf3ba4901159fec84/src/configuration.ts#L11 + */ +public final class MarkdownPreferences { + + enum ServerLog { + off("Off"), + debug("Debug"), + trace("Trace"); + + private ServerLog(final String label) { + this.value = toString(); + this.label = label; + } + + final String value; + final String label; + } + + enum PreferredMdPathExtensionStyle { + auto("Auto"), + includeExtension("Include .md extension"), + removeExtension("Remove .md extension"); + + private PreferredMdPathExtensionStyle(final String label) { + this.value = toString(); + this.label = label; + } + + final String value; + final String label; + } + + enum IncludeWorkspaceHeaderCompletions { + never("Never"), + onSingleOrDoubleHash("On single or double '#'"), + onDoubleHash("Only on double '##'"); + + private IncludeWorkspaceHeaderCompletions(final String label) { + this.value = toString(); + this.label = label; + } + + final String value; + final String label; + } + + enum ValidateEnabled { + ignore("Ignore"), + warning("Warning"), + error("Error"), + hint("Hint"); + + private ValidateEnabled(final String label) { + this.value = toString(); + this.label = label; + } + + final String value; + final String label; + } + + enum ValidateEnabledForFragmentLinks { + ignore("Ignore"), + warning("Warning"), + error("Error"), + hint("Hint"), + inherit("Inherit"); + + private ValidateEnabledForFragmentLinks(final String label) { + this.value = toString(); + this.label = label; + } + + final String value; + final String label; + } + + private static final String MD_SECTION = "markdown"; + + static final String MD_SERVER_LOG = MD_SECTION + ".server.log"; // ServerLog + + static final String MD_OCCURRENCES_HIGHLIGHT_ENABLED = MD_SECTION + ".occurrencesHighlight.enabled"; // boolean + + /* + * Suggest + */ + static final String MD_PREFERRED_MD_PATH_EXTENSION_STYLE = MD_SECTION + ".preferredMdPathExtensionStyle"; // PreferredMdPathExtensionStyle + static final String MD_SUGGEST_PATHS_ENABLED = MD_SECTION + ".suggest.paths.enabled"; // boolean + static final String MD_SUGGEST_PATHS_INCLUDE_WKS_HEADER_COMPLETIONS = MD_SECTION + + ".suggest.paths.includeWorkspaceHeaderCompletions"; // IncludeWorkspaceHeaderCompletions + + /* + * Validation + */ + private static final String MD_VADLIDATE_SECTION = MD_SECTION + ".validate"; + static final String MD_VALIDATE_ENABLED = MD_VADLIDATE_SECTION + ".enabled"; // boolean + static final String MD_VALIDATE_REFERENCE_LINKS_ENABLED = MD_VADLIDATE_SECTION + ".referenceLinks.enabled"; // ValidateEnabled + static final String MD_VALIDATE_FRAGMENT_LINKS_ENABLED = MD_VADLIDATE_SECTION + ".fragmentLinks.enabled"; // ValidateEnabled + static final String MD_VALIDATE_FILE_LINKS_ENABLED = MD_VADLIDATE_SECTION + ".fileLinks.enabled"; // ValidateEnabled + static final String MD_VALIDATE_FILE_LINKS_MARKDOWN_FRAGMENT_LINKS = MD_VADLIDATE_SECTION + ".fileLinks.markdownFragmentLinks"; // ValidateEnabledForFragmentLinks + static final String MD_VALIDATE_IGNORED_LINKS = MD_VADLIDATE_SECTION + ".ignoredLinks"; // comma list + static final String MD_VALIDATE_UNUSED_LINK_DEFS_ENABLED = MD_VADLIDATE_SECTION + ".unusedLinkDefinitions.enabled"; // ValidateEnabled + static final String MD_VALIDATE_DUPLICATE_LINK_DEFS_ENABLED = MD_VADLIDATE_SECTION + ".duplicateLinkDefinitions.enabled"; // ValidateEnabled + + public static Settings getGlobalSettings() { + final IPreferenceStore store = Activator.getDefault().getPreferenceStore(); + final var settings = new Settings(store); + + settings.fillAsString(MD_SERVER_LOG); + settings.fillAsBoolean(MD_OCCURRENCES_HIGHLIGHT_ENABLED); + + /* + * Suggest + */ + settings.fillAsString(MD_PREFERRED_MD_PATH_EXTENSION_STYLE); + settings.fillAsBoolean(MD_SUGGEST_PATHS_ENABLED); + settings.fillSetting(MD_SUGGEST_PATHS_INCLUDE_WKS_HEADER_COMPLETIONS, + store.getString(MD_SUGGEST_PATHS_INCLUDE_WKS_HEADER_COMPLETIONS)); + + /* + * Validation + */ + // Top-level enabled is boolean true per server type + settings.fillAsBoolean(MD_VALIDATE_ENABLED); + settings.fillAsString(MD_VALIDATE_REFERENCE_LINKS_ENABLED); + settings.fillAsString(MD_VALIDATE_FRAGMENT_LINKS_ENABLED); + settings.fillAsString(MD_VALIDATE_FILE_LINKS_ENABLED); + settings.fillAsString(MD_VALIDATE_FILE_LINKS_MARKDOWN_FRAGMENT_LINKS); + // Build ignoredLinks as array, filtering out empty entries to avoid server error + final String rawIgnored = store.getString(MD_VALIDATE_IGNORED_LINKS); + if (rawIgnored == null || rawIgnored.trim().isEmpty()) { + settings.fillSetting(MD_VALIDATE_IGNORED_LINKS, new String[0]); + } else { + final String[] globs = Arrays.stream(rawIgnored.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new); + settings.fillSetting(MD_VALIDATE_IGNORED_LINKS, globs); + } + settings.fillAsString(MD_VALIDATE_UNUSED_LINK_DEFS_ENABLED); + settings.fillAsString(MD_VALIDATE_DUPLICATE_LINK_DEFS_ENABLED); + return settings; + } + + public static boolean isMatchMarkdownSection(final String section) { + return isMatchSection(section, MD_SECTION); + } + + private MarkdownPreferences() { + } +} diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/util/FileUtils.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/util/FileUtils.java index 24b0c5ec9d..2d61995a4d 100644 --- a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/util/FileUtils.java +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/util/FileUtils.java @@ -15,6 +15,7 @@ import java.io.File; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.Path; import java.nio.file.Paths; import org.eclipse.core.runtime.IStatus; @@ -22,14 +23,18 @@ import org.eclipse.wildwebdeveloper.Activator; public class FileUtils { - private static final String FILE_SCHEME = "file"; //$NON-NLS-1$ + public static final String FILE_SCHEME = "file"; //$NON-NLS-1$ - public static File fromUri(String uri) { + public static File uriToFile(String uri) { // not using `new File(new URI(uri))` here which does not support Windows UNC paths // and instead throws IllegalArgumentException("URI has an authority component") return Paths.get(URI.create(uri)).toFile(); } + public static Path uriToPath(String uri) { + return Paths.get(URI.create(uri)); + } + public static URI toUri(String filePath) { return toUri(new File(filePath)); }