From 0c90b001e0bf560341d0018e5eef41a870497ae2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:51:54 +0000 Subject: [PATCH 01/10] Bound HTML table width and give each cache caller its own future HTML tables padded every row to the widest row with no column cap, so a ~1 MB table of one wide row over many short ones grew as rows x columns and ran out of memory. Cells past the 128-column cap (the Markdown limit) now join the last column, keeping their text. BackgroundCache handed the same in-flight future to every request and prefetch caller, so one caller cancelling or timing it out broke every other caller and later requests until the build finished. Each caller now gets its own follower future. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwoQ7jw5qzPPePBj6NEpaS --- .../ghosthack/docview/BackgroundCache.java | 16 ++++++++++++--- .../ghosthack/docview/HtmlBlockParser.java | 13 +++++++++++- .../ghosthack/docview/BlocksHtmlTest.java | 20 +++++++++++++++++++ .../ghosthack/docview/PageCacheTest.java | 19 ++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/io/github/ghosthack/docview/BackgroundCache.java b/core/src/main/java/io/github/ghosthack/docview/BackgroundCache.java index bd6ad17..898dd5f 100644 --- a/core/src/main/java/io/github/ghosthack/docview/BackgroundCache.java +++ b/core/src/main/java/io/github/ghosthack/docview/BackgroundCache.java @@ -74,7 +74,7 @@ public CompletableFuture request(K key, DocumentContent document) { V cached = cache.get(key); if (cached != null) return CompletableFuture.completedFuture(cached); CompletableFuture inFlight = building.get(key); - if (inFlight != null) return inFlight; + if (inFlight != null) return follower(inFlight); future = new CompletableFuture<>(); building.put(key, future); } @@ -86,7 +86,7 @@ public CompletableFuture request(K key, DocumentContent document) { } future.cancel(false); } - return future; + return follower(future); } /** @@ -101,7 +101,7 @@ public CompletableFuture prefetch(K key, DocumentContent document) { V cached = cache.get(key); if (cached != null) return CompletableFuture.completedFuture(cached); CompletableFuture inFlight = building.get(key); - if (inFlight != null) return inFlight; + if (inFlight != null) return follower(inFlight); } var result = new CompletableFuture(); Runnable task = () -> { @@ -150,6 +150,16 @@ private static CompletableFuture cancelled() { return future; } + /** + * A caller's own view of a shared build: cancelling or completing it + * leaves the build and every other caller untouched. + */ + private static CompletableFuture follower(CompletableFuture build) { + var result = new CompletableFuture(); + build.whenComplete((value, error) -> forward(value, error, result)); + return result; + } + private static void forward(V value, Throwable error, CompletableFuture result) { if (error != null) result.completeExceptionally(error); diff --git a/core/src/main/java/io/github/ghosthack/docview/HtmlBlockParser.java b/core/src/main/java/io/github/ghosthack/docview/HtmlBlockParser.java index 59e12a1..cabba2c 100644 --- a/core/src/main/java/io/github/ghosthack/docview/HtmlBlockParser.java +++ b/core/src/main/java/io/github/ghosthack/docview/HtmlBlockParser.java @@ -214,8 +214,19 @@ public void handleEndTag(HTML.Tag tag, int pos) { } else if (tag == HTML.Tag.TH || tag == HTML.Tag.TD) { if (tableCells != null) { flushRun(); - tableCells.add(trimRuns(List.copyOf(runs))); + List cell = trimRuns(List.copyOf(runs)); runs.clear(); + if (tableCells.size() < SafeMarkupRenderer.MAX_TABLE_COLUMNS) { + tableCells.add(cell); + } else if (!cell.isEmpty()) { + // Cells past the column cap join the last column, so + // padding stays bounded by the cap times the rows + // instead of growing as rows x widest row. + var last = new ArrayList<>(tableCells.getLast()); + if (!last.isEmpty()) last.add(new Run(" ", false, false, false, null)); + last.addAll(cell); + tableCells.set(tableCells.size() - 1, List.copyOf(last)); + } } } else if (tag == HTML.Tag.TR) { if (tableRows != null && tableCells != null && !tableCells.isEmpty()) { diff --git a/core/src/test/java/io/github/ghosthack/docview/BlocksHtmlTest.java b/core/src/test/java/io/github/ghosthack/docview/BlocksHtmlTest.java index 2fea7d5..5f8c8d1 100644 --- a/core/src/test/java/io/github/ghosthack/docview/BlocksHtmlTest.java +++ b/core/src/test/java/io/github/ghosthack/docview/BlocksHtmlTest.java @@ -142,6 +142,26 @@ void nestingPastTheDepthCapFallsBackToSourceLinesQuickly() { Blocks.Model nested = Blocks.fromHtml("
".repeat(100) + "deep" + "
".repeat(100)); assertEquals("deep", text(assertInstanceOf(Paragraph.class, nested.rows().getFirst()).runs())); } + @Test + void wideHtmlTableOverManyRowsStaysBounded() { + int n = 32_000; // about 0.9 MB of HTML; rows x widest row was 1G cells + String html = "" + "".repeat(n) + "" + + "".repeat(n) + "
a
b
"; + + List rows = assertTimeoutPreemptively(Duration.ofSeconds(20), + () -> Blocks.fromHtml(html).rows()); + + assertEquals(n + 1, rows.size()); + var first = assertInstanceOf(TableRow.class, rows.getFirst()); + assertEquals(SafeMarkupRenderer.MAX_TABLE_COLUMNS, first.cells().size()); + assertEquals(SafeMarkupRenderer.MAX_TABLE_COLUMNS, first.table().columns()); + int expectedTail = n - SafeMarkupRenderer.MAX_TABLE_COLUMNS + 1; + assertEquals(("a ".repeat(expectedTail)).strip(), text(first.cells().getLast()), + "cells past the cap join the last column"); + var second = assertInstanceOf(TableRow.class, rows.get(1)); + assertEquals("b", text(second.cells().getFirst())); + } + private static String text(List runs) { var out = new StringBuilder(); for (Run run : runs) out.append(run.text()); diff --git a/core/src/test/java/io/github/ghosthack/docview/PageCacheTest.java b/core/src/test/java/io/github/ghosthack/docview/PageCacheTest.java index 3a81257..f5ea679 100644 --- a/core/src/test/java/io/github/ghosthack/docview/PageCacheTest.java +++ b/core/src/test/java/io/github/ghosthack/docview/PageCacheTest.java @@ -11,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -91,6 +92,24 @@ void concurrentRequestsShareOneBuild() { assertSame(one.join(), two.join()); } + @Test + void cancellingOneRequestLeavesTheSharedBuildRunning() throws Exception { + DocumentContent doc = markdown("e.md", "```java\nclass A {}\n```\n".repeat(400)); + var key = PageCache.Key.of(doc, STYLE, HighlightEngines.AUTOMATIC); + CompletableFuture prefetched = pages.prefetch(key, doc); + CompletableFuture abandoned = pages.request(key, doc); + CompletableFuture other = pages.request(key, doc); + + assertNotSame(abandoned, other, "every caller gets its own future"); + abandoned.cancel(false); + prefetched.orTimeout(1, TimeUnit.NANOSECONDS); + CompletableFuture again = pages.request(key, doc); + + assertEquals(SafeMarkupRenderer.page(doc, STYLE, HighlightEngines.AUTOMATIC), + again.get(30, TimeUnit.SECONDS)); + assertSame(again.join(), other.get(30, TimeUnit.SECONDS)); + } + @Test void hashCollidingEditsAreDistinctPages() throws Exception { // "Aa" and "BB" share a String hash code, length and UTF-8 size. From 94148e8ee0f46ce149fbfc4269a61b6a7b6b438c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:54:10 +0000 Subject: [PATCH 02/10] Remember the scrolled-to row until the views have laid out BlockView and XmlTreeView saved a document's scroll position from the flow's first visible cell, which only moves on a layout pass. Showing two documents before a pulse (fast keyboard browsing) saved the outgoing document with the previous document's row, so it later reopened scrolled to an unrelated position. Each view now keeps the row it last scrolled to until its next layout pass and reports that row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwoQ7jw5qzPPePBj6NEpaS --- .../ghosthack/docview/fx/BlockView.java | 25 +++++++++-- .../ghosthack/docview/fx/XmlTreeView.java | 9 ++++ .../ghosthack/docview/fx/BlockViewTest.java | 41 +++++++++++++++++++ .../ghosthack/docview/fx/XmlTreeViewTest.java | 31 ++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/fx/src/main/java/io/github/ghosthack/docview/fx/BlockView.java b/fx/src/main/java/io/github/ghosthack/docview/fx/BlockView.java index 8149f04..ff9890a 100644 --- a/fx/src/main/java/io/github/ghosthack/docview/fx/BlockView.java +++ b/fx/src/main/java/io/github/ghosthack/docview/fx/BlockView.java @@ -120,6 +120,12 @@ protected boolean removeEldestEntry(Map.Entry eldest) { /** Link text under the last primary press; only it may activate on release. */ private Text pressedLink; + /** + * The row last scrolled to, until a layout pass has moved the flow there; + * until then the flow's first visible cell still shows the old layout. + */ + private Integer pendingRow; + /** A caret position: a row and an offset into its {@link #rowText}. */ record Position(int row, int offset) implements Comparable { @Override @@ -130,6 +136,9 @@ public int compareTo(Position other) { } public BlockView() { + list.needsLayoutProperty().addListener((property, was, now) -> { + if (!now) pendingRow = null; + }); list.setFocusTraversable(true); applyListStyle(); list.setCellFactory(ignored -> new RowCell()); @@ -207,7 +216,7 @@ public void show(Path documentPath, Blocks.Model next, ReaderStyle readerStyle) fonts = new Fonts(readerStyle); list.getItems().setAll(next.rows()); int row = rememberedRows.getOrDefault(documentPath, 0); - list.scrollTo(Math.max(0, Math.min(row, next.rows().size() - 1))); + scrollToRow(Math.max(0, Math.min(row, next.rows().size() - 1))); } /** Records the current document's first visible row for a later return. */ @@ -220,6 +229,7 @@ public void clear() { anchor = focus = null; model = null; path = null; + pendingRow = null; list.getItems().clear(); } @@ -227,7 +237,7 @@ public boolean scrollToAnchor(String anchor) { if (model == null) return false; int row = model.rowOfAnchor(anchor); if (row < 0) return false; - list.scrollTo(row); + scrollToRow(row); return true; } @@ -235,7 +245,14 @@ public void requestFocus() { list.requestFocus(); } + private void scrollToRow(int row) { + list.scrollTo(row); + pendingRow = row; + list.requestLayout(); + } + private int firstVisibleRow() { + if (pendingRow != null) return pendingRow; VirtualFlow flow = flow(); if (flow == null || flow.getFirstVisibleCell() == null) return 0; return flow.getFirstVisibleCell().getIndex(); @@ -269,8 +286,8 @@ private void scrollKey(KeyEvent event) { case DOWN, J -> flow.scrollPixels(ARROW_STEP); case PAGE_UP -> flow.scrollPixels(-page); case PAGE_DOWN, SPACE -> flow.scrollPixels(event.isShiftDown() ? -page : page); - case HOME -> list.scrollTo(0); - case END -> list.scrollTo(Math.max(0, list.getItems().size() - 1)); + case HOME -> scrollToRow(0); + case END -> scrollToRow(Math.max(0, list.getItems().size() - 1)); default -> { return; } diff --git a/fx/src/main/java/io/github/ghosthack/docview/fx/XmlTreeView.java b/fx/src/main/java/io/github/ghosthack/docview/fx/XmlTreeView.java index 7ec9bce..57fe380 100644 --- a/fx/src/main/java/io/github/ghosthack/docview/fx/XmlTreeView.java +++ b/fx/src/main/java/io/github/ghosthack/docview/fx/XmlTreeView.java @@ -68,6 +68,8 @@ public final class XmlTreeView { private final BorderPane root = new BorderPane(); private final TreeTableView table = new TreeTableView<>(); + /** The row last scrolled to, until a layout pass has moved the flow there. */ + private Integer pendingRow; private ReaderTheme theme = ReaderTheme.dark(); private final TreeTableColumn tagColumn = new TreeTableColumn<>("NODE TAG"); private final TreeTableColumn valueColumn = @@ -101,6 +103,9 @@ private record Remembered(XmlTree.Tree tree, XmlItem root, int firstRow, } public XmlTreeView() { + table.needsLayoutProperty().addListener((property, was, now) -> { + if (!now) pendingRow = null; + }); root.getStyleClass().add("docview-xml-tree"); applyThemeStyle(); var css = XmlTreeView.class.getResource("xml-tree-view.css"); @@ -235,6 +240,9 @@ public void show(Path documentPath, XmlTree.Tree next, ReaderStyle style, String layoutColumns(); table.getSelectionModel().clearSelection(); table.scrollTo(restored ? state.firstRow() : 0); + // Until a layout pass, the flow still shows the previous document. + pendingRow = restored ? state.firstRow() : 0; + table.requestLayout(); table.refresh(); } @@ -346,6 +354,7 @@ private void key(KeyEvent event) { } private int firstVisibleRow() { + if (pendingRow != null) return pendingRow; if (table.lookup(".virtual-flow") instanceof VirtualFlow flow && flow.getFirstVisibleCell() != null) { return flow.getFirstVisibleCell().getIndex(); diff --git a/fx/src/test/java/io/github/ghosthack/docview/fx/BlockViewTest.java b/fx/src/test/java/io/github/ghosthack/docview/fx/BlockViewTest.java index 232a3f6..b9d7e09 100644 --- a/fx/src/test/java/io/github/ghosthack/docview/fx/BlockViewTest.java +++ b/fx/src/test/java/io/github/ghosthack/docview/fx/BlockViewTest.java @@ -45,6 +45,47 @@ void copiesOmitMarkersAndLineNumbers() { assertEquals("", BlockView.rowText(rows.get(5))); } + @Test + void scrollMemorySurvivesShowsBetweenLayoutPasses() { + Fx.run(() -> { + var reader = new BlockView(); + var root = new StackPane(reader.node()); + new Scene(root, 800, 600); + var list = (javafx.scene.control.ListView) reader.node(); + var style = ReaderStyle.defaults(); + Blocks.Model a = paragraphs("A"), b = paragraphs("B"), c = paragraphs("C"); + + reader.show(Path.of("a.md"), a, style); + root.applyCss(); + root.layout(); + list.scrollTo(300); + root.layout(); + int rowOfA = firstVisible(list); + assertTrue(rowOfA > 250, "A is scrolled down"); + // Fast browsing: B and C are shown before any layout pass. + reader.show(Path.of("b.md"), b, style); + reader.show(Path.of("c.md"), c, style); + root.layout(); + reader.show(Path.of("b.md"), b, style); + root.layout(); + assertEquals(0, firstVisible(list), "B was never scrolled"); + reader.show(Path.of("a.md"), a, style); + root.layout(); + assertEquals(rowOfA, firstVisible(list), "A keeps its row"); + }); + } + + private static Blocks.Model paragraphs(String name) { + var text = new StringBuilder(); + for (int i = 0; i < 1000; i++) text.append(name).append(' ').append(i).append("\n\n"); + return Blocks.fromMarkdown(text.toString(), HighlightEngines.NONE); + } + + static int firstVisible(javafx.scene.Parent control) { + var flow = (javafx.scene.control.skin.VirtualFlow) control.lookup(".virtual-flow"); + return flow.getFirstVisibleCell().getIndex(); + } + @Test void positionsOrderByRowThenOffset() { var early = new BlockView.Position(1, 9); diff --git a/fx/src/test/java/io/github/ghosthack/docview/fx/XmlTreeViewTest.java b/fx/src/test/java/io/github/ghosthack/docview/fx/XmlTreeViewTest.java index ade6bfb..0771ecb 100644 --- a/fx/src/test/java/io/github/ghosthack/docview/fx/XmlTreeViewTest.java +++ b/fx/src/test/java/io/github/ghosthack/docview/fx/XmlTreeViewTest.java @@ -43,6 +43,37 @@ void rememberedDocumentFollowsLeafToggleChangedElsewhere() { }); } + @Test + void scrollMemorySurvivesShowsBetweenLayoutPasses() { + Fx.run(() -> { + var view = new XmlTreeView(); + var root = new javafx.scene.layout.StackPane(view.node()); + new javafx.scene.Scene(root, 800, 600); + var style = ReaderStyle.defaults(); + var source = new StringBuilder(""); + for (int i = 0; i < 500; i++) source.append("x1"); + String text = source.append("").toString(); + XmlTree.Tree x = XmlTree.parse(text), y = XmlTree.parse(text), z = XmlTree.parse(text); + + view.show(Path.of("x.xml"), x, style, text); + root.applyCss(); + root.layout(); + table(view).scrollTo(200); + root.layout(); + int rowOfX = BlockViewTest.firstVisible(table(view)); + assertTrue(rowOfX > 150, "X is scrolled down"); + view.show(Path.of("y.xml"), y, style, text); + view.show(Path.of("z.xml"), z, style, text); + root.layout(); + view.show(Path.of("y.xml"), y, style, text); + root.layout(); + assertEquals(0, BlockViewTest.firstVisible(table(view)), "Y was never scrolled"); + view.show(Path.of("x.xml"), x, style, text); + root.layout(); + assertEquals(rowOfX, BlockViewTest.firstVisible(table(view)), "X keeps its row"); + }); + } + @Test void clearRemovesParseErrorPanel() { Fx.run(() -> { From fb1cc77378353f9e5a7cf8eedd30d8f409162015 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:55:29 +0000 Subject: [PATCH 03/10] Let modified keys pass through BlockView's scroll keys The reader's key filter matched UP, DOWN, J, K, HOME, END and the page keys without looking at modifiers and consumed them, so application accelerators such as Ctrl+K, Ctrl+J, Cmd+Up or Ctrl+End scrolled the reader instead of firing while it had focus. Keys with Ctrl, Alt, Meta or the shortcut modifier now pass through; Shift still pages back with Space. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwoQ7jw5qzPPePBj6NEpaS --- .../ghosthack/docview/fx/BlockView.java | 6 ++++ .../ghosthack/docview/fx/BlockViewTest.java | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/fx/src/main/java/io/github/ghosthack/docview/fx/BlockView.java b/fx/src/main/java/io/github/ghosthack/docview/fx/BlockView.java index ff9890a..4c2d675 100644 --- a/fx/src/main/java/io/github/ghosthack/docview/fx/BlockView.java +++ b/fx/src/main/java/io/github/ghosthack/docview/fx/BlockView.java @@ -278,6 +278,12 @@ private void scrollKey(KeyEvent event) { event.consume(); return; } + // Leave modified keys (Ctrl+K, Cmd+Up, Ctrl+End, ...) to the + // application's own accelerators. + if (event.isShortcutDown() || event.isControlDown() + || event.isAltDown() || event.isMetaDown()) { + return; + } VirtualFlow flow = flow(); if (flow == null) return; double page = Math.max(ARROW_STEP, list.getHeight() - ARROW_STEP); diff --git a/fx/src/test/java/io/github/ghosthack/docview/fx/BlockViewTest.java b/fx/src/test/java/io/github/ghosthack/docview/fx/BlockViewTest.java index b9d7e09..22d308d 100644 --- a/fx/src/test/java/io/github/ghosthack/docview/fx/BlockViewTest.java +++ b/fx/src/test/java/io/github/ghosthack/docview/fx/BlockViewTest.java @@ -75,6 +75,38 @@ void scrollMemorySurvivesShowsBetweenLayoutPasses() { }); } + @Test + void scrollKeysLeaveModifiedKeysToTheApplication() { + Fx.run(() -> { + var reader = new BlockView(); + var root = new StackPane(reader.node()); + new Scene(root, 800, 600); + reader.show(Path.of("a.md"), paragraphs("A"), ReaderStyle.defaults()); + root.applyCss(); + root.layout(); + var reached = new ArrayList(); + reader.node().addEventHandler(javafx.scene.input.KeyEvent.KEY_PRESSED, + event -> reached.add(event.getCode())); + + press(reader.node(), javafx.scene.input.KeyCode.K, false); + press(reader.node(), javafx.scene.input.KeyCode.DOWN, false); + assertTrue(reached.isEmpty(), "plain scroll keys are consumed"); + + for (var code : List.of(javafx.scene.input.KeyCode.K, javafx.scene.input.KeyCode.J, + javafx.scene.input.KeyCode.UP, javafx.scene.input.KeyCode.END)) { + press(reader.node(), code, true); + } + assertEquals(List.of(javafx.scene.input.KeyCode.K, javafx.scene.input.KeyCode.J, + javafx.scene.input.KeyCode.UP, javafx.scene.input.KeyCode.END), reached); + }); + } + + private static void press(Node target, javafx.scene.input.KeyCode code, boolean control) { + javafx.event.Event.fireEvent(target, new javafx.scene.input.KeyEvent( + javafx.scene.input.KeyEvent.KEY_PRESSED, "", "", code, + false, control, false, false)); + } + private static Blocks.Model paragraphs(String name) { var text = new StringBuilder(); for (int i = 0; i < 1000; i++) text.append(name).append(' ').append(i).append("\n\n"); From a2450a9abd47d7f0572630d40df2b28ae58614ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:55:59 +0000 Subject: [PATCH 04/10] Let modified Space pass through XmlTreeView's branch toggle Ctrl+Space and similar accelerators toggled the focused branch and were consumed while the tree had focus; only a plain Space toggles now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwoQ7jw5qzPPePBj6NEpaS --- .../main/java/io/github/ghosthack/docview/fx/XmlTreeView.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fx/src/main/java/io/github/ghosthack/docview/fx/XmlTreeView.java b/fx/src/main/java/io/github/ghosthack/docview/fx/XmlTreeView.java index 57fe380..85df6d9 100644 --- a/fx/src/main/java/io/github/ghosthack/docview/fx/XmlTreeView.java +++ b/fx/src/main/java/io/github/ghosthack/docview/fx/XmlTreeView.java @@ -346,7 +346,9 @@ private void key(KeyEvent event) { } else if (selectAllKey != null && selectAllKey.match(event)) { table.getSelectionModel().selectAll(); event.consume(); - } else if (event.getCode() == KeyCode.SPACE) { + } else if (event.getCode() == KeyCode.SPACE && !event.isShortcutDown() + && !event.isControlDown() && !event.isAltDown() && !event.isMetaDown()) { + // Modified Space (Ctrl+Space, ...) stays with the application. TreeItem focused = table.getFocusModel().getFocusedItem(); if (focused != null && !focused.isLeaf()) focused.setExpanded(!focused.isExpanded()); event.consume(); From 2be9383756da1b2d0cfae33c46c3d94e52baaacb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:57:23 +0000 Subject: [PATCH 05/10] Pin the release to the published commit and fence the signing key Every release job checked out the tag by name, so a tag force-moved while the workflow ran could publish code that validate and CI never saw. All three checkouts now use the event's commit, and validate fails if the tag no longer points at it. The deploy and the manual bundle dry run, which both sign with the release key, now run in a maven-central environment that can carry required reviewers and the secrets, and the dry run is limited to main. The dry run also checks that every artifact in the bundle is signed instead of only that a bundle exists, and the help and versions plugins it invokes by prefix are pinned. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwoQ7jw5qzPPePBj6NEpaS --- .github/workflows/release.yml | 47 +++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d300002..6803719 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,9 +24,11 @@ jobs: if: github.event_name == 'release' && github.event.release.prerelease == false runs-on: ubuntu-latest steps: + # github.sha is the tag's commit when the release was published, so a + # tag moved while this workflow runs cannot swap in untested code. - uses: actions/checkout@v6 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ github.sha }} - uses: actions/setup-java@v5 with: distribution: temurin @@ -36,7 +38,7 @@ jobs: env: RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - VERSION="$(./mvnw -q -N help:evaluate -Dexpression=project.version -DforceStdout)" + VERSION="$(./mvnw -q -N org.apache.maven.plugins:maven-help-plugin:3.5.2:evaluate -Dexpression=project.version -DforceStdout)" if [[ "$VERSION" == *-SNAPSHOT ]]; then echo "::error::Cannot release snapshot version $VERSION" exit 1 @@ -45,20 +47,33 @@ jobs: echo "::error::Release tag $RELEASE_TAG must match project version v$VERSION" exit 1 fi + # The checkout is the event's commit only; fetch the tag as it is now. + git fetch --no-tags --depth=1 origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + TAGGED="$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}" 2>/dev/null || true)" + if [[ "$TAGGED" != "$GITHUB_SHA" ]]; then + echo "::error::Tag $RELEASE_TAG now points at ${TAGGED:-nothing}, not the released commit $GITHUB_SHA" + exit 1 + fi build: needs: validate uses: ./.github/workflows/ci.yml with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ github.sha }} deploy: needs: build runs-on: ubuntu-latest + # Protect this environment (required reviewers; deployments from v* tags + # and main only) and keep the Central and GPG secrets there instead of in + # repository secrets. + environment: maven-central steps: + # github.sha is the tag's commit when the release was published, so a + # tag moved while this workflow runs cannot swap in untested code. - uses: actions/checkout@v6 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ github.sha }} - name: Set up JDK 21 uses: actions/setup-java@v5 with: @@ -79,10 +94,13 @@ jobs: # Manual runs only build and sign the Central bundle (never uploaded) so it # can be inspected and validated without publishing. The job only gets - # placeholder Central credentials, so it cannot upload anything. + # placeholder Central credentials, so it cannot upload anything. It signs + # with the release key, so it runs only from main and in the same + # protected environment as the deploy. bundle-dry-run: - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + environment: maven-central steps: - uses: actions/checkout@v6 - name: Set up JDK 21 @@ -100,12 +118,25 @@ jobs: run: | ./mvnw -v if [[ -n "$DRY_RUN_VERSION" ]]; then - ./mvnw -B -q versions:set -DnewVersion="$DRY_RUN_VERSION" -DgenerateBackupPoms=false + ./mvnw -B -q org.codehaus.mojo:versions-maven-plugin:2.23.0:set -DnewVersion="$DRY_RUN_VERSION" -DgenerateBackupPoms=false fi # skipPublishing would also skip bundling, so let the upload fail # against an unreachable URL instead and keep the bundle it built. + # The upload is the only step allowed to fail: everything before it + # must have produced a bundle with a signature for every artifact. ./mvnw -B clean deploy -P release -DskipTests -DcentralBaseUrl=http://127.0.0.1:9 || true - test -f target/central-publishing/central-bundle.zip + BUNDLE=target/central-publishing/central-bundle.zip + test -f "$BUNDLE" + FILES="$(unzip -Z1 "$BUNDLE")" + UNSIGNED=0 + while read -r file; do + case "$file" in */|*.asc|*.md5|*.sha1|*.sha256|*.sha512) continue ;; esac + if ! grep -qxF "$file.asc" <<<"$FILES"; then + echo "::error::$file has no signature in the bundle" + UNSIGNED=1 + fi + done <<<"$FILES" + [[ "$UNSIGNED" == 0 ]] unzip -Z -v target/central-publishing/central-bundle.zip | grep -E '^(Central directory entry| file system or operating system of origin| version of encoding software| minimum software version| general purpose bit flag| compression method| file name|There is)' || true unzip -l target/central-publishing/central-bundle.zip env: From a8fd33904a8d49f22a7f68b4336e25a0a4ab5174 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:59:31 +0000 Subject: [PATCH 06/10] Ship the MIT notices of bundled grammars and TM4E's origins The docview-textmate jar carried only the VS Code MIT license and the TextMate Bundle License, but most grammars come from other MIT upstreams (MagicStack, Shopify, GitHub/Atom, Jeff Hykin, Microsoft grammar repos, the Atom Java grammar via redhat-developer), and MIT requires each one's copyright and permission notice to travel with the copies. The vendored TM4E core likewise translates MIT code from vscode-textmate, vscode and node-oniguruma, whose notices were only in source headers that compiled classes drop. Add grammars/THIRD-PARTY-NOTICES.txt with each upstream's notice at the pinned commit (VS Code's fuller cgmanifest notice where it records one), and tm4e/NOTICE-MIT.txt packaged under META-INF/licenses/tm4e/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwoQ7jw5qzPPePBj6NEpaS --- THIRD-PARTY.md | 9 +- textmate/pom.xml | 1 + .../docview/textmate/grammars/NOTICE.md | 9 +- .../textmate/grammars/THIRD-PARTY-NOTICES.txt | 305 ++++++++++++++++++ textmate/src/main/tm4e/NOTICE-MIT.txt | 86 +++++ textmate/src/main/tm4e/README.md | 4 +- 6 files changed, 407 insertions(+), 7 deletions(-) create mode 100644 textmate/src/main/resources/io/github/ghosthack/docview/textmate/grammars/THIRD-PARTY-NOTICES.txt create mode 100644 textmate/src/main/tm4e/NOTICE-MIT.txt diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index 47f5d9c..7471a08 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -6,15 +6,18 @@ [eclipse-tm4e/tm4e](https://github.com/eclipse-tm4e/tm4e) at commit `4b15c1b2c1d24b06556065d5006804ab56e9c211` and relocated from `org.eclipse.tm4e.*` to `io.github.ghosthack.docview.textmate.internal.tm4e.*`. - Eclipse Public License 2.0. - Source, license and the list of local changes: `textmate/src/main/tm4e/` + Eclipse Public License 2.0. Parts of it are Java translations of MIT code + from microsoft/vscode-textmate, microsoft/vscode and atom/node-oniguruma; + their notices are in `NOTICE-MIT.txt`. + Source, licenses and the list of local changes: `textmate/src/main/tm4e/` (also packaged in the jar under `META-INF/licenses/tm4e/`). - **TextMate grammars** for CSS, HTML, Java, JavaScript, JSON, Markdown, Python, Ruby, shell, SQL, TypeScript, XML and YAML, from the VS Code repository and upstream grammar projects, under MIT or the TextMate Bundle License. Per-file sources, commits and licenses: `textmate/src/main/resources/io/github/ghosthack/docview/textmate/grammars/` - (`NOTICE.md`, `LICENSE-VSCODE.txt`, `LICENSE-TEXTMATE-BUNDLES.txt`), + (`NOTICE.md`, `THIRD-PARTY-NOTICES.txt`, `LICENSE-VSCODE.txt`, + `LICENSE-TEXTMATE-BUNDLES.txt`), packaged alongside the grammars. Runtime dependencies of docview-textmate (resolved from Maven Central): Gson, diff --git a/textmate/pom.xml b/textmate/pom.xml index 76c8ea8..f6cfaba 100644 --- a/textmate/pom.xml +++ b/textmate/pom.xml @@ -75,6 +75,7 @@ META-INF/licenses/tm4e LICENSE + NOTICE-MIT.txt README.md diff --git a/textmate/src/main/resources/io/github/ghosthack/docview/textmate/grammars/NOTICE.md b/textmate/src/main/resources/io/github/ghosthack/docview/textmate/grammars/NOTICE.md index 62b695f..a81cc85 100644 --- a/textmate/src/main/resources/io/github/ghosthack/docview/textmate/grammars/NOTICE.md +++ b/textmate/src/main/resources/io/github/ghosthack/docview/textmate/grammars/NOTICE.md @@ -6,7 +6,9 @@ repository at commit `3cd0ed67506d43e342dcf2835e719cd64de68ffc`, which bundles them from the upstream projects below (see each extension's `cgmanifest.json` there). The VS Code repository's MIT license is included as `LICENSE-VSCODE.txt`; the -TextMate Bundle License is included as `LICENSE-TEXTMATE-BUNDLES.txt`. +TextMate Bundle License is included as `LICENSE-TEXTMATE-BUNDLES.txt`; the +copyright and permission notice of each MIT upstream below is reproduced in +`THIRD-PARTY-NOTICES.txt`. | File | Scope | Upstream (commit) | License | |---|---|---|---| @@ -26,5 +28,6 @@ TextMate Bundle License is included as `LICENSE-TEXTMATE-BUNDLES.txt`. | `xml.tmLanguage.json` | `text.xml` | [atom/language-xml](https://github.com/atom/language-xml) (`7bc75df`) | MIT | | `yaml.tmLanguage.json` | `source.yaml` | [textmate/yaml.tmbundle](https://github.com/textmate/yaml.tmbundle) (`e54ceae`), converted by VS Code | TextMate Bundle License | -The Eclipse TM4E core that tokenizes these grammars is EPL-2.0; its license -and notes ship in this jar under `META-INF/licenses/tm4e/`. +The Eclipse TM4E core that tokenizes these grammars is EPL-2.0; its license, +notes and the MIT notices of the code it derives from ship in this jar under +`META-INF/licenses/tm4e/`. diff --git a/textmate/src/main/resources/io/github/ghosthack/docview/textmate/grammars/THIRD-PARTY-NOTICES.txt b/textmate/src/main/resources/io/github/ghosthack/docview/textmate/grammars/THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000..040ec8b --- /dev/null +++ b/textmate/src/main/resources/io/github/ghosthack/docview/textmate/grammars/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,305 @@ +Third-party notices for the bundled TextMate grammars + +The grammars below are distributed under the MIT License (with the noted +earlier attributions). Each upstream's copyright and permission notice is +reproduced in full, as recorded by the upstream project at the listed commit +(or, where VS Code records a fuller notice in its cgmanifest.json at +microsoft/vscode@3cd0ed67506d43e342dcf2835e719cd64de68ffc, that notice). +Grammars under the TextMate Bundle License are covered by +LICENSE-TEXTMATE-BUNDLES.txt; the VS Code conversion by LICENSE-VSCODE.txt. + +============================================================================== +css.tmLanguage.json +From: https://github.com/microsoft/vscode-css +Commit: 9a07d76cb0e7a56f9bfc76328a57227751e4adb4 +============================================================================== + +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------- + +This package was derived from a TextMate bundle located at +https://github.com/textmate/css.tmbundle and distributed under the following +license, located in `README.mdown`: + +Permission to copy, use, modify, sell and distribute this +software is granted. This software is provided "as is" without +express or implied warranty, and with no claim as to its +suitability for any purpose. + +============================================================================== +java.tmLanguage.json +From: https://github.com/redhat-developer/vscode-java (language-support/java, derived from atom/language-java) +Commit: f09b712f5d6d6339e765f58c8dfab3f78a378183 +============================================================================== + +Copyright (c) 2014 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------- + +This package was derived from a TextMate bundle located at +https://github.com/textmate/java.tmbundle and distributed under the following +license, located in `README.mdown`: + +Permission to copy, use, modify, sell and distribute this +software is granted. This software is provided "as is" without +express or implied warranty, and with no claim as to its +suitability for any purpose. + +============================================================================== +javascript.tmLanguage.json, typescript.tmLanguage.json +From: https://github.com/microsoft/TypeScript-TmLanguage +Commit: 48f608692aa6d6ad7bd65b478187906c798234a8 +============================================================================== + +Copyright (c) Microsoft Corporation +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +============================================================================== +json.tmLanguage.json +From: https://github.com/microsoft/vscode-JSON.tmLanguage +Commit: 9bd83f1c252b375e957203f21793316203f61f70 +============================================================================== + +The MIT License (MIT) + +Copyright (c) 2015 simonzack, zertosh, benvie + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +python.tmLanguage.json, python-regexp.tmLanguage.json +From: https://github.com/MagicStack/MagicPython +Commit: 7d0f2b22a5ad8fccbd7341bc7b7a715169283044 and c9b3409deb69acec31bbf7913830e93a046b30cc +============================================================================== + +The MIT License + +Copyright (c) 2015-present MagicStack Inc. http://magic.io + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +============================================================================== +ruby.tmLanguage.json +From: https://github.com/Shopify/ruby-lsp +Commit: ba41f8b4f9677fb14c1ecbe15d73ebe12a0d3859 +============================================================================== + +The MIT License (MIT) + +Copyright (c) 2022-present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +The following files and related configuration in package.json are based on a +sequence of adaptions: grammars/ruby.cson.json, grammars/erb.cson.json, +languages/erb.json. + +Copyright (c) 2016 Peng Lv +Copyright (c) 2017-2019 Stafford Brunk +https://github.com/rubyide/vscode-ruby + + Released under the MIT license + https://github.com/rubyide/vscode-ruby/blob/main/LICENSE.txt + +Copyright (c) 2014 GitHub Inc. +https://github.com/atom/language-ruby + + Released under the MIT license + https://github.com/atom/language-ruby/blob/master/LICENSE.md + +https://github.com/textmate/ruby.tmbundle + https://github.com/textmate/ruby.tmbundle#license + +============================================================================== +shell.tmLanguage.json +From: https://github.com/jeff-hykin/better-shell-syntax +Commit: 35020b0bd79a90d3b262b4c13a8bb0b33adc1f45 +============================================================================== + +MIT License + +Copyright (c) 2019 Jeff Hykin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +sql.tmLanguage.json +From: https://github.com/microsoft/vscode-mssql +Commit: c002f514dd81fa71fa304d4c36f8d2767dbf2f9d +============================================================================== + + ------------------------------------------ START OF LICENSE ----------------------------------------- +vscode-mssql +Copyright (c) Microsoft Corporation +All rights reserved. +MIT License +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Copyright (c) 2016 Microsoft +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +----------------------------------------------- END OF LICENSE ----------------------------------------- + +============================================================================== +xml.tmLanguage.json +From: https://github.com/atom/language-xml +Commit: 7bc75dfe779ad5b35d9bf4013d9181864358cb49 +============================================================================== + +Copyright (c) 2014 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------- + +This package was derived from a TextMate bundle located at +https://github.com/textmate/xml.tmbundle and distributed under the following +license, located in `README.mdown`: + +Permission to copy, use, modify, sell and distribute this +software is granted. This software is provided "as is" without +express or implied warranty, and with no claim as to its +suitability for any purpose. + diff --git a/textmate/src/main/tm4e/NOTICE-MIT.txt b/textmate/src/main/tm4e/NOTICE-MIT.txt new file mode 100644 index 0000000..3c634ba --- /dev/null +++ b/textmate/src/main/tm4e/NOTICE-MIT.txt @@ -0,0 +1,86 @@ +MIT notices for code the vendored Eclipse TM4E core derives from + +Parts of TM4E are Java translations of MIT-licensed code; their source +headers name the original authors. TM4E itself is EPL-2.0 (see LICENSE). +The original copyright and permission notices follow. + +============================================================================== +microsoft/vscode-textmate +From: https://github.com/microsoft/vscode-textmate (LICENSE.md) +============================================================================== + +The MIT License (MIT) + +Copyright (c) Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +microsoft/vscode +From: https://github.com/microsoft/vscode (LICENSE.txt) +============================================================================== + +MIT License + +Copyright (c) 2015 - present Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +============================================================================== +atom/node-oniguruma +From: https://github.com/atom/node-oniguruma (LICENSE.md) +============================================================================== + +Copyright (c) 2013 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/textmate/src/main/tm4e/README.md b/textmate/src/main/tm4e/README.md index d6f1f24..ad2b67c 100644 --- a/textmate/src/main/tm4e/README.md +++ b/textmate/src/main/tm4e/README.md @@ -6,7 +6,9 @@ This directory vendors the pure-Java core of additional Maven source root because TM4E releases are distributed as Eclipse/p2 bundles rather than Maven Central artifacts. -The source remains under EPL-2.0; see `LICENSE`. Changes made for docview: +The source remains under EPL-2.0; see `LICENSE`. Code that TM4E translated +from MIT projects keeps those notices in `NOTICE-MIT.txt`. Changes made for +docview: - packages relocated from `org.eclipse.tm4e` to `io.github.ghosthack.docview.textmate.internal.tm4e`, so docview never From f226fb2394961daa49f13323ca3b8e455b099b89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 05:00:39 +0000 Subject: [PATCH 07/10] Drop the maven-central environment from the release workflow The project has a single maintainer, so an approval gate and environment-scoped secrets add setup without guarding against anyone. The release stays pinned to the published commit, and the dry run stays limited to main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwoQ7jw5qzPPePBj6NEpaS --- .github/workflows/release.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6803719..37a9b6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,10 +64,6 @@ jobs: deploy: needs: build runs-on: ubuntu-latest - # Protect this environment (required reviewers; deployments from v* tags - # and main only) and keep the Central and GPG secrets there instead of in - # repository secrets. - environment: maven-central steps: # github.sha is the tag's commit when the release was published, so a # tag moved while this workflow runs cannot swap in untested code. @@ -95,12 +91,10 @@ jobs: # Manual runs only build and sign the Central bundle (never uploaded) so it # can be inspected and validated without publishing. The job only gets # placeholder Central credentials, so it cannot upload anything. It signs - # with the release key, so it runs only from main and in the same - # protected environment as the deploy. + # with the release key, so it runs only from main. bundle-dry-run: if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest - environment: maven-central steps: - uses: actions/checkout@v6 - name: Set up JDK 21 From e3b8e5cb58a4b38145c008d8fb1ea3f75bf75d56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 05:04:52 +0000 Subject: [PATCH 08/10] Charge highlight budgets for search time and cap each call The per-document budget timed whole tokensForLine calls, and the TextMate session budget timed whole tokenizeLine calls. Both counted grammar rule compilation and time spent waiting for the shared grammar lock while another document (or a low-priority prefetch) tokenized, so concurrent builds drained a document's budget. The document budget was also only checked before each call, while one TextMate call could tokenize every earlier line of a block and run up to the 1 s session budget past it. HighlightSession gains a default tokensForLine(line, maxNanos, spent): the budget passes its remaining time and is charged what the session reports. The default keeps the old behaviour (wall time, no allowance), and the guarded wrapper forwards it and charges wall time to a session that fails before reporting. TextMate caps each line's deadline at the smaller of 100 ms, the session's remaining budget and the call's allowance, and reports only regex search time. LineDeadline also no longer counts a just-finished search twice when the watchdog races its bookkeeping, and keeps a thread interrupt that Joni turned into an InterruptedException and cleared. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwoQ7jw5qzPPePBj6NEpaS --- README.md | 10 +-- .../ghosthack/docview/HighlightBudget.java | 19 +++--- .../ghosthack/docview/HighlightEngines.java | 32 +++++++-- .../ghosthack/docview/HighlightSession.java | 20 ++++++ .../docview/HighlightBudgetTest.java | 67 +++++++++++++++++++ .../textmate/TextMateHighlightEngine.java | 47 ++++++++++--- .../textmate/internal/LineDeadline.java | 25 ++++++- .../textmate/TextMateHighlightEngineTest.java | 37 ++++++++++ 8 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 core/src/test/java/io/github/ghosthack/docview/HighlightBudgetTest.java diff --git a/README.md b/README.md index 44926dd..66e0ea4 100644 --- a/README.md +++ b/README.md @@ -141,10 +141,12 @@ native views use. - Markdown quote and list nesting is capped at depth 32, and inline parsing is linear in the line length. - Highlighting is bounded: TextMate skips lines over 2,000 characters, - interrupts a line once its regex searches have taken 100 ms (one-time - grammar compilation is not counted), and stops after about 1 s per code - block; each parsed document also has a shared budget of about - 1.5 s. Past these limits code renders plain. + interrupts a line once its regex searches have taken 100 ms, and stops + after 1 s of search time per code block; each parsed document also has a + shared budget of 1.5 s, which a single call cannot run far past. For TextMate + these limits count regex search time only, so one-time grammar + compilation and waiting on another document's highlighting never use them + up. Past these limits code renders plain. - When XML cannot be shown as a tree, at most 1 Mi characters of its source are shown instead. diff --git a/core/src/main/java/io/github/ghosthack/docview/HighlightBudget.java b/core/src/main/java/io/github/ghosthack/docview/HighlightBudget.java index 571250c..a5b9cbc 100644 --- a/core/src/main/java/io/github/ghosthack/docview/HighlightBudget.java +++ b/core/src/main/java/io/github/ghosthack/docview/HighlightBudget.java @@ -27,14 +27,17 @@ boolean exhausted() { return spentNanos.get() >= limitNanos; } - /** Tokens for one line, or plain tokens once the budget is spent. */ + /** + * Tokens for one line, or plain tokens once the budget is spent. The + * session gets the remaining budget as its allowance, so one call (which + * may tokenize every earlier line of a block) cannot run far past it, and + * is charged the tokenizing time it reports. + */ java.util.List tokens(HighlightSession session, int line, String text) { - if (session == null || exhausted()) return HighlightEngines.plainLine(text); - long started = System.nanoTime(); - try { - return session.tokensForLine(line); - } finally { - spentNanos.addAndGet(System.nanoTime() - started); - } + if (session == null) return HighlightEngines.plainLine(text); + long remaining = limitNanos - spentNanos.get(); + if (remaining <= 0) return HighlightEngines.plainLine(text); + return session.tokensForLine(line, remaining, + spent -> spentNanos.addAndGet(Math.max(0, spent))); } } diff --git a/core/src/main/java/io/github/ghosthack/docview/HighlightEngines.java b/core/src/main/java/io/github/ghosthack/docview/HighlightEngines.java index 1f3dfd5..acd6daa 100644 --- a/core/src/main/java/io/github/ghosthack/docview/HighlightEngines.java +++ b/core/src/main/java/io/github/ghosthack/docview/HighlightEngines.java @@ -187,16 +187,38 @@ private static HighlightSession guarded( public List tokensForLine(int lineIndex) { String line = lineText(document, lineIndex); try { - List tokens = - delegate.tokensForLine(lineIndex); - return validPartition(tokens, line.length()) - ? List.copyOf(tokens) - : plainTokens(line); + return checked(delegate.tokensForLine(lineIndex), line); } catch (RuntimeException | LinkageError | StackOverflowError ignored) { return plainTokens(line); } } + @Override + public List tokensForLine(int lineIndex, long maxNanos, + java.util.function.LongConsumer spent) { + String line = lineText(document, lineIndex); + long started = System.nanoTime(); + var reported = new boolean[1]; + try { + return checked(delegate.tokensForLine(lineIndex, maxNanos, nanos -> { + reported[0] = true; + spent.accept(nanos); + }), line); + } catch (RuntimeException | LinkageError | StackOverflowError ignored) { + return plainTokens(line); + } finally { + // A session that failed (or forgot) to report is charged + // its wall time, so a broken engine cannot dodge the budget. + if (!reported[0]) spent.accept(System.nanoTime() - started); + } + } + + private List checked(List tokens, String line) { + return validPartition(tokens, line.length()) + ? List.copyOf(tokens) + : plainTokens(line); + } + @Override public void close() { try { diff --git a/core/src/main/java/io/github/ghosthack/docview/HighlightSession.java b/core/src/main/java/io/github/ghosthack/docview/HighlightSession.java index ef4b92f..9904a13 100644 --- a/core/src/main/java/io/github/ghosthack/docview/HighlightSession.java +++ b/core/src/main/java/io/github/ghosthack/docview/HighlightSession.java @@ -1,6 +1,7 @@ package io.github.ghosthack.docview; import java.util.List; +import java.util.function.LongConsumer; /** * Document-scoped highlighting state. Stateful engines may cache lexer @@ -10,6 +11,25 @@ public interface HighlightSession extends AutoCloseable { List tokensForLine(int lineIndex); + /** + * Like {@link #tokensForLine(int)}, within a time allowance: a session + * that has to tokenize earlier lines first should stop spending once + * {@code maxNanos} is used and return plain tokens for the rest. The time + * the call actually spent tokenizing is reported to {@code spent}, which + * lets an engine leave out one-time setup or waiting on other documents. + * + *

The default ignores the allowance and reports the call's wall time. + */ + default List tokensForLine(int lineIndex, long maxNanos, + LongConsumer spent) { + long started = System.nanoTime(); + try { + return tokensForLine(lineIndex); + } finally { + spent.accept(System.nanoTime() - started); + } + } + @Override default void close() { } diff --git a/core/src/test/java/io/github/ghosthack/docview/HighlightBudgetTest.java b/core/src/test/java/io/github/ghosthack/docview/HighlightBudgetTest.java new file mode 100644 index 0000000..af632a7 --- /dev/null +++ b/core/src/test/java/io/github/ghosthack/docview/HighlightBudgetTest.java @@ -0,0 +1,67 @@ +package io.github.ghosthack.docview; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.LongConsumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class HighlightBudgetTest { + + /** Reports a fixed tokenizing time and records each allowance it gets. */ + private static final class ReportingSession implements HighlightSession { + final List allowances = new ArrayList<>(); + final long reports; + + ReportingSession(long reports) { + this.reports = reports; + } + + @Override + public List tokensForLine(int lineIndex) { + throw new AssertionError("the budget passes an allowance"); + } + + @Override + public List tokensForLine(int lineIndex, long maxNanos, + LongConsumer spent) { + allowances.add(maxNanos); + spent.accept(reports); + return List.of(new HighlightToken(0, 1, HighlightRole.KEYWORD)); + } + } + + @Test + void passesTheRemainingBudgetAndChargesReportedTime() { + var budget = new HighlightBudget(1_000); + var session = new ReportingSession(400); + + budget.tokens(session, 0, "x"); + budget.tokens(session, 1, "x"); + assertFalse(budget.exhausted()); + budget.tokens(session, 2, "x"); + + assertEquals(List.of(1_000L, 600L, 200L), session.allowances); + assertTrue(budget.exhausted()); + assertEquals(HighlightEngines.plainLine("x"), budget.tokens(session, 3, "x")); + assertEquals(3, session.allowances.size(), "no call once spent"); + } + + @Test + void sessionsWithoutAllowanceSupportAreChargedWallTime() { + var budget = new HighlightBudget(1); + HighlightSession slow = line -> { + long until = System.nanoTime() + 2_000_000; + while (System.nanoTime() < until) Thread.onSpinWait(); + return List.of(new HighlightToken(0, 1, HighlightRole.KEYWORD)); + }; + + budget.tokens(slow, 0, "x"); + + assertTrue(budget.exhausted()); + } +} diff --git a/textmate/src/main/java/io/github/ghosthack/docview/textmate/TextMateHighlightEngine.java b/textmate/src/main/java/io/github/ghosthack/docview/textmate/TextMateHighlightEngine.java index 0247477..47e4b5d 100644 --- a/textmate/src/main/java/io/github/ghosthack/docview/textmate/TextMateHighlightEngine.java +++ b/textmate/src/main/java/io/github/ghosthack/docview/textmate/TextMateHighlightEngine.java @@ -17,6 +17,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.function.LongConsumer; import io.github.ghosthack.docview.textmate.internal.tm4e.core.grammar.IGrammar; import io.github.ghosthack.docview.textmate.internal.tm4e.core.grammar.IStateStack; @@ -53,7 +54,7 @@ public final class TextMateHighlightEngine implements HighlightEngine { * compilation and cut the first lines after a cold start short. */ static final Duration LINE_HARD_LIMIT = Duration.ofMillis(100); - /** Total tokenizing time per session (code block or document). */ + /** Total regex search time per session (code block or document). */ static final long SESSION_BUDGET_NANOS = 1_000_000_000L; private static final Map GRAMMARS = @@ -188,16 +189,41 @@ private Session(DocumentContent document, IGrammar grammar) { this.tokens = new ArrayList<>(document.lineCount()); } + @Override + public List tokensForLine(int lineIndex) { + return tokensForLine(lineIndex, Long.MAX_VALUE, spent -> { }); + } + + /** + * Budgets count regex search time only: one-time grammar compilation + * and waiting for another document using the same grammar are left + * out, so a cold start or a concurrent build does not use them up. + */ @Override public synchronized List tokensForLine( - int lineIndex) { + int lineIndex, long maxNanos, LongConsumer spent) { HighlightEngines.lineText(document, lineIndex); + long callNanos = 0; + try { + callNanos = tokenizeThrough(lineIndex, maxNanos); + } finally { + spent.accept(callNanos); + } + return tokens.get(lineIndex); + } + + /** Tokenizes up to {@code lineIndex}; returns the search time used. */ + private long tokenizeThrough(int lineIndex, long maxNanos) { + long callNanos = 0; while (tokens.size() <= lineIndex) { int nextLine = tokens.size(); String line = HighlightEngines.lineText(document, nextLine); - if (line.length() > MAX_LINE_LENGTH - || spentNanos >= SESSION_BUDGET_NANOS) { + long allowance = Math.min( + Math.min(LINE_HARD_LIMIT.toNanos(), + SESSION_BUDGET_NANOS - spentNanos), + maxNanos - callNanos); + if (line.length() > MAX_LINE_LENGTH || allowance <= 0) { // The skipped line may have closed a comment or string; // restart from the initial state rather than carry a // possibly stale one into the following lines. @@ -206,20 +232,21 @@ public synchronized List tokensForLine( continue; } ITokenizeLineResult result; + long[] searched = new long[1]; synchronized (grammar) { - long started = System.nanoTime(); try { IStateStack stack = ruleStack; - result = LineDeadline.within(LINE_HARD_LIMIT, - () -> grammar.tokenizeLine(line, stack, null)); + result = LineDeadline.within(allowance, + () -> grammar.tokenizeLine(line, stack, null), + nanos -> searched[0] = nanos); } catch (RuntimeException failed) { // Expired, or a grammar rule Joni cannot compile // (TMException); either way move past this line. result = null; - } finally { - spentNanos += System.nanoTime() - started; } } + spentNanos += searched[0]; + callNanos += searched[0]; if (result == null || result.isStoppedEarly()) { // Partial tokens and a mid-line rule stack would corrupt // the following lines. @@ -230,7 +257,7 @@ public synchronized List tokensForLine( ruleStack = result.getRuleStack(); tokens.add(convert(line, result.getTokens())); } - return tokens.get(lineIndex); + return callNanos; } } diff --git a/textmate/src/main/java/io/github/ghosthack/docview/textmate/internal/LineDeadline.java b/textmate/src/main/java/io/github/ghosthack/docview/textmate/internal/LineDeadline.java index d1af031..2d9438d 100644 --- a/textmate/src/main/java/io/github/ghosthack/docview/textmate/internal/LineDeadline.java +++ b/textmate/src/main/java/io/github/ghosthack/docview/textmate/internal/LineDeadline.java @@ -3,6 +3,7 @@ import java.time.Duration; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongConsumer; import java.util.function.Supplier; import org.joni.Matcher; @@ -50,7 +51,16 @@ private LineDeadline(long limitNanos) { /** Runs {@code action} on this thread with a deadline of {@code limit}. */ public static T within(Duration limit, Supplier action) { - var deadline = new LineDeadline(limit.toNanos()); + return within(limit.toNanos(), action, searched -> { }); + } + + /** + * Runs {@code action} with a deadline of {@code limitNanos} of search + * time, then reports the search time it used to {@code searched}, also + * when it expired or failed. + */ + public static T within(long limitNanos, Supplier action, LongConsumer searched) { + var deadline = new LineDeadline(limitNanos); LineDeadline outer = CURRENT.get(); CURRENT.set(deadline); ACTIVE.add(deadline); @@ -61,6 +71,7 @@ public static T within(Duration limit, Supplier action) { ACTIVE.remove(deadline); if (outer == null) CURRENT.remove(); else CURRENT.set(outer); + searched.accept(deadline.searchedNanos); } } @@ -81,8 +92,14 @@ public static int search(Matcher matcher, int start, int end) { if (deadline.expired) throw new Expired(); return matcher.searchInterruptible(start, end, Option.DEFAULT); } catch (InterruptedException interrupted) { + // Joni also throws (and clears the flag) for a thread interrupt, + // such as an executor shutting down; keep that one visible. + if (!deadline.expired) Thread.currentThread().interrupt(); throw new Expired(); } finally { + // Clear runningSince before adding to searchedNanos: the watchdog + // re-reads runningSince after searchedNanos, so it never counts a + // finished search twice. deadline.running = null; deadline.runningSince = 0; deadline.searchedNanos += System.nanoTime() - started; @@ -112,7 +129,11 @@ private static void watch() { Matcher matcher = deadline.running; long since = deadline.runningSince; if (matcher == null || since == 0) continue; - if (deadline.searchedNanos + (now - since) < deadline.limitNanos) continue; + long searched = deadline.searchedNanos; + // Unchanged runningSince means searchedNanos does not yet + // include this search (the owner clears it first). + if (deadline.runningSince != since) continue; + if (searched + (now - since) < deadline.limitNanos) continue; deadline.expired = true; matcher.interrupt(); } diff --git a/textmate/src/test/java/io/github/ghosthack/docview/textmate/TextMateHighlightEngineTest.java b/textmate/src/test/java/io/github/ghosthack/docview/textmate/TextMateHighlightEngineTest.java index 7ef57bf..9f490a4 100644 --- a/textmate/src/test/java/io/github/ghosthack/docview/textmate/TextMateHighlightEngineTest.java +++ b/textmate/src/test/java/io/github/ghosthack/docview/textmate/TextMateHighlightEngineTest.java @@ -43,6 +43,43 @@ void oneHostileLineIsCutOffByTheHardDeadline() { org.junit.jupiter.api.Assertions.assertTrue(millis < 5_000, "took " + millis + " ms"); } + @Test + void oneCallStopsAtTheAllowanceItWasGiven() { + // A late line of a hostile block tokenizes every earlier line first; + // before allowances, that one call could spend the whole 1 s session + // budget past an almost spent document budget. + String text = ("`".repeat(1_999) + "\n").repeat(20); + DocumentContent document = DocumentContent.of(Path.of("a.md"), DocumentKind.MARKDOWN, text); + long allowance = 50_000_000L; + var reported = new java.util.concurrent.atomic.AtomicLong(-1); + try (HighlightSession session = + HighlightEngines.open(HighlightEngines.TEXTMATE, document)) { + long started = System.nanoTime(); + var tokens = session.tokensForLine(19, allowance, reported::set); + long millis = (System.nanoTime() - started) / 1_000_000; + + org.junit.jupiter.api.Assertions.assertEquals( + HighlightEngines.plainLine("`".repeat(1_999)), tokens); + org.junit.jupiter.api.Assertions.assertTrue(millis < 500, "took " + millis + " ms"); + org.junit.jupiter.api.Assertions.assertTrue(reported.get() >= 0, "time reported"); + org.junit.jupiter.api.Assertions.assertTrue(reported.get() < allowance + 30_000_000L, + "reported " + reported.get() / 1_000_000 + " ms"); + } + } + + @Test + void aZeroAllowanceTokenizesNothing() { + String text = "int x = 1;\n"; + DocumentContent document = DocumentContent.of(Path.of("A.java"), DocumentKind.JAVA, text); + var reported = new java.util.concurrent.atomic.AtomicLong(-1); + try (HighlightSession session = HighlightEngines.open(HighlightEngines.TEXTMATE, document)) { + org.junit.jupiter.api.Assertions.assertEquals( + HighlightEngines.plainLine("int x = 1;"), + session.tokensForLine(0, 0, reported::set)); + org.junit.jupiter.api.Assertions.assertEquals(0, reported.get()); + } + } + @Test void aSkippedLongLineDoesNotLeaveAStaleComment() { String text = "/*\n" + "x".repeat(2_500) + " */\nint x = 1;\n"; From 69f3d276aa55fbb0b7e4852ed5451d25f0400ee6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 05:07:55 +0000 Subject: [PATCH 09/10] Fix four Markdown and HTML display mismatches - Ordered lists in WebView pages now carry a start attribute from their first marker, so "3. a" shows 3 and an ordered run after a bullet run continues its numbering, as the native reader already showed. - Thematic breaks may have spaces between markers ("* * *", "- - -", "_ _ _"), per CommonMark; they rendered as an emphasized list item or paragraph. One shared check now replaces five copies of the pattern, and mixed markers ("- * -") are no longer a rule. - The contents tree takes heading text from the renderer's own inline output instead of separate patterns, so "snake_case_name" and a code span holding link syntax read the same as on the page. - An object or applet element no longer hides everything after it when left unclosed: its tags are dropped and its fallback content is shown through the same whitelist, as a browser without plugins does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwoQ7jw5qzPPePBj6NEpaS --- README.md | 3 +- .../io/github/ghosthack/docview/Blocks.java | 2 +- .../ghosthack/docview/MarkdownOutline.java | 22 +++------ .../ghosthack/docview/SafeMarkupRenderer.java | 46 +++++++++++++++++-- .../ghosthack/docview/BlocksHtmlTest.java | 15 +++++- .../ghosthack/docview/BlocksMarkdownTest.java | 10 ++++ .../docview/MarkdownOutlineTest.java | 15 ++++++ .../docview/SafeMarkupRendererTest.java | 12 +++++ 8 files changed, 102 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 66e0ea4..89ab175 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,8 @@ native views use. ## Safety - HTML passes a structural whitelist: scripts, styles, active attributes, - images, frames, objects and remote resources are discarded. Link targets + images, frames, objects and remote resources are discarded (an object's + fallback content is shown, like a browser without plugins). Link targets survive only as inert metadata; nothing navigates or fetches. - Generated WebView pages carry a deny-by-default content security policy. - XML is parsed with external entities, external DTDs, schemas and XInclude diff --git a/core/src/main/java/io/github/ghosthack/docview/Blocks.java b/core/src/main/java/io/github/ghosthack/docview/Blocks.java index 41c3f9c..6b39de4 100644 --- a/core/src/main/java/io/github/ghosthack/docview/Blocks.java +++ b/core/src/main/java/io/github/ghosthack/docview/Blocks.java @@ -299,7 +299,7 @@ private static void parse(String markdown, String engine, int quoteDepth, anchor, quoteDepth)); continue; } - if (trimmed.matches("[-*_]{3,}")) { + if (SafeMarkupRenderer.isThematicBreak(trimmed)) { rows.add(new Rule(quoteDepth)); continue; } diff --git a/core/src/main/java/io/github/ghosthack/docview/MarkdownOutline.java b/core/src/main/java/io/github/ghosthack/docview/MarkdownOutline.java index 80d958d..ee0783d 100644 --- a/core/src/main/java/io/github/ghosthack/docview/MarkdownOutline.java +++ b/core/src/main/java/io/github/ghosthack/docview/MarkdownOutline.java @@ -11,15 +11,6 @@ public final class MarkdownOutline { private static final Pattern HEADING = Pattern.compile("^(#{1,6})\\s+(.*)$"); - // Same linear link shape as SafeMarkupRenderer: no brackets in the label - // and no parentheses in the target, so each attempt stops early. - private static final Pattern LINK = Pattern.compile( - "!?\\[([^\\[\\]]+)]\\(([^()]*)\\)"); - private static final Pattern CODE = Pattern.compile("`([^`]*)`"); - private static final Pattern STRONG = - Pattern.compile("\\*\\*([^*]+)\\*\\*|__([^_]+)__"); - private static final Pattern EMPHASIS = Pattern.compile( - "(? headings(String markdown) { headings.add(new Heading( match.group(1).length(), displayText(match.group(2)), anchor(headings.size()))); - } else if (trimmed.matches("[-*_]{3,}")) { + } else if (SafeMarkupRenderer.isThematicBreak(trimmed)) { continue; } else if (trimmed.startsWith(">")) { while (lineIndex + 1 < lines.length @@ -85,11 +76,12 @@ public static String anchor(int headingIndex) { return "md-heading-" + headingIndex; } + /** The heading's text exactly as both readers display it. */ private static String displayText(String markdown) { - String text = LINK.matcher(markdown).replaceAll("$1"); - text = CODE.matcher(text).replaceAll("$1"); - text = STRONG.matcher(text).replaceAll("$1$2"); - text = EMPHASIS.matcher(text).replaceAll("$1$2"); - return text.isBlank() ? "Untitled heading" : text.strip(); + var text = new StringBuilder(); + for (Blocks.Run run : Blocks.runs(SafeMarkupRenderer.inline(markdown))) { + text.append(run.text()); + } + return text.toString().isBlank() ? "Untitled heading" : text.toString().strip(); } } diff --git a/core/src/main/java/io/github/ghosthack/docview/SafeMarkupRenderer.java b/core/src/main/java/io/github/ghosthack/docview/SafeMarkupRenderer.java index 765308b..e4cc04d 100644 --- a/core/src/main/java/io/github/ghosthack/docview/SafeMarkupRenderer.java +++ b/core/src/main/java/io/github/ghosthack/docview/SafeMarkupRenderer.java @@ -65,9 +65,14 @@ public final class SafeMarkupRenderer { HTML.Tag.TD, HTML.Tag.CAPTION, HTML.Tag.DIV, HTML.Tag.SPAN, HTML.Tag.B, HTML.Tag.I, HTML.Tag.U, HTML.Tag.S, HTML.Tag.SUB, HTML.Tag.SUP, HTML.Tag.A); + /** + * Elements dropped with their content. OBJECT and APPLET are not here: + * like a browser without plugins, their tags are dropped and their + * fallback content is shown (through the same whitelist), so an unclosed + * one no longer hides the rest of the document. + */ static final Set SUPPRESSED = Set.of( - HTML.Tag.SCRIPT, HTML.Tag.STYLE, HTML.Tag.TITLE, - HTML.Tag.OBJECT, HTML.Tag.APPLET); + HTML.Tag.SCRIPT, HTML.Tag.STYLE, HTML.Tag.TITLE); private static final Set RAW_TEXT_SUPPRESSED = Set.of( "script", "style", "title"); private static final Pattern INLINE_CODE = Pattern.compile("`([^`]+)`"); @@ -90,6 +95,9 @@ public final class SafeMarkupRenderer { private static final Pattern BULLET = Pattern.compile("^[-+*]\\s+(.*)$"); private static final Pattern NUMBERED = Pattern.compile("^\\d+[.)]\\s+(.*)$"); private static final Pattern GRAPHEME = Pattern.compile("\\X"); + /** Three or more of one of - * _, optionally separated by spaces or tabs. */ + private static final Pattern THEMATIC_BREAK = Pattern.compile( + "(?:-[ \\t]*){3,}|(?:\\*[ \\t]*){3,}|(?:_[ \\t]*){3,}"); private static final Pattern LIST_ITEM = Pattern.compile( "^(\\s*)([-+*]|(\\d+[.)]))\\s+(.*)$"); private static final Pattern UNCHECKED_TASK = Pattern.compile( @@ -555,7 +563,7 @@ private static String markdownBody( .append("'); continue; } - if (trimmed.matches("[-*_]{3,}")) { + if (SafeMarkupRenderer.isThematicBreak(trimmed)) { out.append("


"); continue; } @@ -680,10 +688,15 @@ private static boolean startsNonListBlock( return trimmed.startsWith("```") || trimmed.startsWith(">") || HEADING.matcher(trimmed).matches() - || trimmed.matches("[-*_]{3,}") + || SafeMarkupRenderer.isThematicBreak(trimmed) || tableHeader(lines, lineIndex) != null; } + /** A thematic break ({@code ---}, {@code * * *}, {@code _ _ _}) on a stripped line. */ + static boolean isThematicBreak(String trimmed) { + return THEMATIC_BREAK.matcher(trimmed).matches(); + } + static ListMarker listMarker(String line) { Matcher matcher = LIST_ITEM.matcher(line); if (!matcher.matches()) return null; @@ -729,6 +742,13 @@ private static void appendListGroups( } String tag = ordered ? "ol" : "ul"; out.append('<').append(tag); + if (ordered) { + // Number like the source (and the native reader): from the + // first item's marker, so "3." starts at 3 and an ordered + // run after a bullet continues instead of restarting at 1. + String number = startNumber(nodes.get(start).marker()); + if (number != null) out.append(" start=\"").append(number).append('"'); + } if (taskTree) out.append(" class=\"task-tree\""); out.append('>'); for (int index = start; index < end; index++) { @@ -743,6 +763,22 @@ private static void appendListGroups( } } + /** + * The start attribute value for an ordered marker such as {@code 3.}: + * its number without leading zeros, or null for 1 (the default) and for + * numbers over nine digits (as CommonMark limits them). + */ + static String startNumber(String marker) { + int digits = 0; + while (digits < marker.length() + && marker.charAt(digits) >= '0' && marker.charAt(digits) <= '9') { + digits++; + } + String number = marker.substring(0, digits).replaceFirst("^0+(?=\\d)", ""); + if (number.isEmpty() || number.length() > 9 || number.equals("1")) return null; + return number; + } + private static void appendListNode( StringBuilder out, ListNode node, boolean taskTree, List ancestorLast, @@ -917,7 +953,7 @@ static boolean startsMarkdownBlock(String[] lines, int lineIndex) { || HEADING.matcher(trimmed).matches() || BULLET.matcher(trimmed).matches() || NUMBERED.matcher(trimmed).matches() - || trimmed.matches("[-*_]{3,}")) { + || SafeMarkupRenderer.isThematicBreak(trimmed)) { return true; } return tableHeader(lines, lineIndex) != null; diff --git a/core/src/test/java/io/github/ghosthack/docview/BlocksHtmlTest.java b/core/src/test/java/io/github/ghosthack/docview/BlocksHtmlTest.java index 5f8c8d1..1461c71 100644 --- a/core/src/test/java/io/github/ghosthack/docview/BlocksHtmlTest.java +++ b/core/src/test/java/io/github/ghosthack/docview/BlocksHtmlTest.java @@ -25,7 +25,7 @@ final class BlocksHtmlTest { void dropsScriptsStylesAndTitlesLikeTheWebReader() { List rows = Blocks.fromHtml("T" + "

Kept

" - + "x").rows(); + + "").rows(); assertEquals(1, rows.size()); assertEquals("Kept", text(((Paragraph) rows.getFirst()).runs())); @@ -142,6 +142,19 @@ void nestingPastTheDepthCapFallsBackToSourceLinesQuickly() { Blocks.Model nested = Blocks.fromHtml("
".repeat(100) + "deep" + "
".repeat(100)); assertEquals("deep", text(assertInstanceOf(Paragraph.class, nested.rows().getFirst()).runs())); } + @Test + void anUnclosedObjectShowsItsFallbackInsteadOfHidingTheRest() { + String html = "

a

" + + "

b

c

"; + List rows = Blocks.fromHtml(html).rows(); + + assertEquals(List.of("a", "b", "c"), + rows.stream().map(row -> text(((Paragraph) row).runs())).toList()); + String page = SafeMarkupRenderer.htmlBody(html); + assertTrue(page.contains("

c

"), page); + assertFalse(page.contains("object") || page.contains("param") || page.contains("swf"), page); + } + @Test void wideHtmlTableOverManyRowsStaysBounded() { int n = 32_000; // about 0.9 MB of HTML; rows x widest row was 1G cells diff --git a/core/src/test/java/io/github/ghosthack/docview/BlocksMarkdownTest.java b/core/src/test/java/io/github/ghosthack/docview/BlocksMarkdownTest.java index f517cb0..2bf213a 100644 --- a/core/src/test/java/io/github/ghosthack/docview/BlocksMarkdownTest.java +++ b/core/src/test/java/io/github/ghosthack/docview/BlocksMarkdownTest.java @@ -156,6 +156,16 @@ void quotesNestAndRulesStandAlone() { assertNull(rows.stream().filter(Heading.class::isInstance).findFirst().orElse(null)); } + @Test + void spacedThematicBreaksAreRules() { + for (String rule : List.of("* * *", "- - -", "_ _ _", " - -\t- -", "***", "___")) { + List rows = parse("a\n\n" + rule + "\n\nb").rows(); + assertInstanceOf(Rule.class, rows.get(1), rule); + assertTrue(SafeMarkupRenderer.markdownBody("a\n\n" + rule + "\n\nb").contains("
"), rule); + } + assertFalse(SafeMarkupRenderer.markdownBody("- * -").contains("
"), "mixed markers"); + } + private static Blocks.Model parse(String markdown) { return Blocks.fromMarkdown(markdown, HighlightEngines.AUTOMATIC); } diff --git a/core/src/test/java/io/github/ghosthack/docview/MarkdownOutlineTest.java b/core/src/test/java/io/github/ghosthack/docview/MarkdownOutlineTest.java index 9ab2c63..0094776 100644 --- a/core/src/test/java/io/github/ghosthack/docview/MarkdownOutlineTest.java +++ b/core/src/test/java/io/github/ghosthack/docview/MarkdownOutlineTest.java @@ -28,6 +28,21 @@ void extractsReaderHeadingsWithLevelsLabelsAndStableAnchors() { headings); } + @Test + void contentsTextMatchesTheRenderedHeading() { + String markdown = "# snake_case_name\n## `[a](b)` and **x**\n### a & b\n"; + List headings = MarkdownOutline.headings(markdown); + List rows = Blocks.fromMarkdown(markdown, HighlightEngines.NONE).rows(); + + assertEquals(List.of("snake_case_name", "[a](b) and x", "a & b"), + headings.stream().map(MarkdownOutline.Heading::text).toList()); + for (int i = 0; i < rows.size(); i++) { + var rendered = new StringBuilder(); + ((Blocks.Heading) rows.get(i)).runs().forEach(run -> rendered.append(run.text())); + assertEquals(rendered.toString(), headings.get(i).text()); + } + } + @Test void ignoresHeadingSyntaxInsideFencedCodeAndQuotes() { List headings = MarkdownOutline.headings(""" diff --git a/core/src/test/java/io/github/ghosthack/docview/SafeMarkupRendererTest.java b/core/src/test/java/io/github/ghosthack/docview/SafeMarkupRendererTest.java index 311dad1..97d2d96 100644 --- a/core/src/test/java/io/github/ghosthack/docview/SafeMarkupRendererTest.java +++ b/core/src/test/java/io/github/ghosthack/docview/SafeMarkupRendererTest.java @@ -16,6 +16,18 @@ class SafeMarkupRendererTest { private static final Duration FAST = Duration.ofSeconds(5); + @Test + void orderedListsStartAtTheirFirstMarker() { + assertEquals("
  1. a
  2. b
", + SafeMarkupRenderer.markdownBody("3. a\n4. b")); + assertEquals("
  1. a
  • b
  1. c
", + SafeMarkupRenderer.markdownBody("1. a\n- b\n2. c")); + assertEquals("
  1. x
", + SafeMarkupRenderer.markdownBody("007) x")); + assertEquals("
  1. x
", SafeMarkupRenderer.markdownBody("1234567890. x"), + "over nine digits keeps the default"); + } + private static final ReaderStyle STYLE = new ReaderStyle( "Reader Serif", "Menlo", 15, false, false, From b69293e1dd21d3a22cf24d059c77bb2e5b454c84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 05:51:24 +0000 Subject: [PATCH 10/10] Run the macOS CI job on macos-15 The macos-14 job was cancelled before a runner was assigned on two attempts, with no steps run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VwoQ7jw5qzPPePBj6NEpaS --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4f23b2..7eb6363 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: build: strategy: matrix: - os: [ubuntu-latest, macos-14, windows-latest] + os: [ubuntu-latest, macos-15, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6