Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 33 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
9 changes: 6 additions & 3 deletions THIRD-PARTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public CompletableFuture<V> request(K key, DocumentContent document) {
V cached = cache.get(key);
if (cached != null) return CompletableFuture.completedFuture(cached);
CompletableFuture<V> inFlight = building.get(key);
if (inFlight != null) return inFlight;
if (inFlight != null) return follower(inFlight);
future = new CompletableFuture<>();
building.put(key, future);
}
Expand All @@ -86,7 +86,7 @@ public CompletableFuture<V> request(K key, DocumentContent document) {
}
future.cancel(false);
}
return future;
return follower(future);
}

/**
Expand All @@ -101,7 +101,7 @@ public CompletableFuture<V> prefetch(K key, DocumentContent document) {
V cached = cache.get(key);
if (cached != null) return CompletableFuture.completedFuture(cached);
CompletableFuture<V> inFlight = building.get(key);
if (inFlight != null) return inFlight;
if (inFlight != null) return follower(inFlight);
}
var result = new CompletableFuture<V>();
Runnable task = () -> {
Expand Down Expand Up @@ -150,6 +150,16 @@ private static <V> CompletableFuture<V> 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 <V> CompletableFuture<V> follower(CompletableFuture<V> build) {
var result = new CompletableFuture<V>();
build.whenComplete((value, error) -> forward(value, error, result));
return result;
}

private static <V> void forward(V value, Throwable error,
CompletableFuture<V> result) {
if (error != null) result.completeExceptionally(error);
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/java/io/github/ghosthack/docview/Blocks.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HighlightToken> 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)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,38 @@ private static HighlightSession guarded(
public List<HighlightToken> tokensForLine(int lineIndex) {
String line = lineText(document, lineIndex);
try {
List<HighlightToken> 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<HighlightToken> 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<HighlightToken> checked(List<HighlightToken> tokens, String line) {
return validPartition(tokens, line.length())
? List.copyOf(tokens)
: plainTokens(line);
}

@Override
public void close() {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +11,25 @@ public interface HighlightSession extends AutoCloseable {

List<HighlightToken> 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.
*
* <p>The default ignores the allowance and reports the call's wall time.
*/
default List<HighlightToken> 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() {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Run> 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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"(?<!\\*)\\*([^*]+)\\*(?!\\*)|(?<!_)_([^_]+)_(?!_)");

private MarkdownOutline() {
}
Expand Down Expand Up @@ -62,7 +53,7 @@ public static List<Heading> 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
Expand All @@ -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();
}
}
Loading
Loading