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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d300002..37a9b6b 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,29 @@ 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 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,9 +90,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. + # placeholder Central credentials, so it cannot upload anything. It signs + # with the release key, so it runs only from main. bundle-dry-run: - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -100,12 +112,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: diff --git a/README.md b/README.md index 44926dd..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 @@ -141,10 +142,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/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/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/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/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/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/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 2fea7d5..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,39 @@ 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 + 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/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/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/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/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. 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, 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..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 @@ -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(); @@ -261,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); @@ -269,8 +292,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..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 @@ -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(); } @@ -338,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(); @@ -346,6 +356,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..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 @@ -45,6 +45,79 @@ 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"); + }); + } + + @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"); + 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(() -> { 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/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/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 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";