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
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
package com.williamcallahan.javachat.application.search;

import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand DownExpand Up@@ -94,25 +95,20 @@ public String typePageFileName() {
/**
* Determines whether a Javadoc URL path identifies this selector's declaring type.
*
* <p>Unqualified selectors match the type filename in any package. Qualified selectors require
* the exact package path immediately before that filename, preventing same-named JDK types in
* different packages from receiving the same citation priority.</p>
* <p>Qualified selectors use their query package and ignore the candidate package. Unqualified
* selectors require a canonical Java package derived from the candidate source URL, which
* keeps package-relative API pages from being mistaken for canonical type pages.</p>
*
* @param javadocPath decoded Javadoc URL path
* @return true when the path names this selector's declaring type
* @param candidatePackageName package derived from the candidate source URL, or {@code null}
* when absent
* @return true when the path names this selector's declaring type in its expected package
*/
public boolean matchesJavadocPath(String javadocPath) {
public boolean matchesJavadocPath(String javadocPath, String candidatePackageName) {
Objects.requireNonNull(javadocPath, "javadocPath");
int filenameStartIndex = javadocPath.lastIndexOf('/') + 1;
String candidateFilename = javadocPath.substring(filenameStartIndex);
if (!typePageFileName().equals(candidateFilename)) {
return false;
}
if (packageName.isBlank()) {
return true;
}
String qualifiedPagePathSuffix = "/" + packageName.replace('.', '/') + "/" + typePageFileName();
return javadocPath.endsWith(qualifiedPagePathSuffix);
return expectedPackageName(candidatePackageName)
.map(expectedPackageName -> matchesPackageTypePath(javadocPath, expectedPackageName))
.orElse(false);
}

/**
Expand All@@ -124,6 +120,18 @@ public String sparseQueryTerms() {
return typePageName;
}

private Optional<JavaPackageName> expectedPackageName(String candidatePackageName) {
if (!packageName.isBlank()) {
return JavaPackageName.from(packageName);
}
return JavaPackageName.from(candidatePackageName);
}

private boolean matchesPackageTypePath(String javadocPath, JavaPackageName expectedPackageName) {
String expectedPagePath = "/" + expectedPackageName.javadocPath() + "/" + typePageFileName();
return javadocPath.endsWith(expectedPagePath);
}

private static ParsedQualifiedName parseQualifiedName(String query, int startIndex) {
List<String> segments = new ArrayList<>();
int currentIndex = startIndex;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
package com.williamcallahan.javachat.domain.javaapi;

import java.util.Optional;
import javax.lang.model.SourceVersion;

/**
* Represents an exact, syntactically valid Java 25 package name.
*
* <p>The value preserves its source spelling so URL projections and query selectors share one
* package-identity boundary without trimming or accepting documentation-only path segments.</p>
*
* @param qualifiedName dot-separated Java package name
*/
public record JavaPackageName(String qualifiedName) {

/**
* Enforces exact Java 25 package-name syntax for every constructed value.
*
* @throws IllegalArgumentException when the package name is null, blank, padded, or invalid
*/
public JavaPackageName {
if (!isValid(qualifiedName)) {
throw new IllegalArgumentException("qualifiedName must be an exact Java 25 package name");
}
}

/**
* Creates a package name only when the candidate satisfies the Java 25 name grammar exactly.
*
* @param candidateQualifiedName candidate dot-separated package name
* @return validated package name, or empty when the candidate is absent or invalid
*/
public static Optional<JavaPackageName> from(String candidateQualifiedName) {
return isValid(candidateQualifiedName)
? Optional.of(new JavaPackageName(candidateQualifiedName))
: Optional.empty();
}

/**
* Projects the package into the path syntax used by canonical Javadoc URLs.
*
* @return slash-separated package path without leading or trailing delimiters
*/
public String javadocPath() {
return qualifiedName.replace('.', '/');
}

private static boolean isValid(String candidateQualifiedName) {
return candidateQualifiedName != null
&& !candidateQualifiedName.isBlank()
&& candidateQualifiedName.equals(candidateQualifiedName.trim())
&& SourceVersion.isName(candidateQualifiedName, SourceVersion.RELEASE_25);
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.application.search.JavaApiMethodSelector;
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
Expand DownExpand Up@@ -86,7 +87,8 @@ private static boolean matchesTypePage(JavaApiMethodSelector selector, Document
if (documentPath == null || documentPath.isBlank()) {
return false;
}
return selector.matchesJavadocPath(documentPath);
String candidatePackageName = JavaPackageExtractor.extractJavaApiPackage(sourceUrl);
return selector.matchesJavadocPath(documentPath, candidatePackageName);
}

private static boolean hasMethodDeclarationEvidence(JavaApiMethodSelector selector, Document citationCandidate) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.ModelConfiguration;
import com.williamcallahan.javachat.model.Citation;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor.VersionFilterPatterns;
import java.util.ArrayList;
Expand DownExpand Up@@ -415,10 +416,8 @@ public CitationOutcome toCitations(List<Document> documents) {
Map<String, ?> sourceDocMetadata = sourceDocument.getMetadata();
String rawUrl = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.URL_FIELD);
String title = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.TITLE_FIELD);
String packageName = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.PACKAGE_FIELD);
String documentType = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.DOC_TYPE_FIELD);
String refinedCitationUrl =
refineCitationUrl(rawUrl, sourceDocument.getText(), packageName, documentType);
String refinedCitationUrl = refineCitationUrl(rawUrl, sourceDocument.getText(), documentType);
String citationIdentity = citationIdentityFor(rawUrl, refinedCitationUrl);
if (!citationIdentity.isBlank() && !retainedCitationIdentities.add(citationIdentity)) {
continue;
Expand DownExpand Up@@ -478,10 +477,11 @@ private static String citationIdentityFor(String rawUrl, String citationUrl) {
* Refines a raw document URL and gates Javadoc member anchors to {@code api-docs} metadata.
*
*/
private String refineCitationUrl(String rawUrl, String documentText, String packageName, String documentType) {
private String refineCitationUrl(String rawUrl, String documentText, String documentType) {
String normalizedUrl = DocsSourceRegistry.normalizeDocUrl(rawUrl);
String citationUrl = normalizedUrl;
if (DOCUMENT_TYPE_API_DOCS.equals(documentType)) {
String packageName = JavaPackageExtractor.extractJavaApiPackage(normalizedUrl);
String nestedTypeRefinedUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineNestedTypeUrl(
citationUrl, documentText);
citationUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineMemberAnchorUrl(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource;
import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import com.williamcallahan.javachat.support.AsciiTextNormalizer;
import java.net.URI;
import java.net.URISyntaxException;
Expand DownExpand Up@@ -34,6 +35,25 @@ public static boolean isJavaApiUrl(String url) {
return findJavaApiSourceUrl(url).isPresent();
}

/**
* Derives the Java package encoded by a manifest-governed Java API source URL.
*
* <p>URL consumers use this projection instead of persisted package metadata so package
* identity follows the canonical source path across ingestion generations.</p>
*
* @param url source URL
* @return package encoded by the canonical Java API path, or an empty string when the URL is
* not a canonical Java API source or its path does not identify a package
*/
public static String extractJavaApiPackage(String url) {
Objects.requireNonNull(url, "url");
return findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.flatMap(JavaPackageName::from)
.map(JavaPackageName::qualifiedName)
.orElse("");
}

/**
* Attempts to derive a package name from the URL and extracted page text.
*
Expand All@@ -45,9 +65,7 @@ public static String extractPackage(String url, String bodyText) {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(bodyText, "bodyText");

String pathDerivedPackageName = findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.orElse("");
String pathDerivedPackageName = extractJavaApiPackage(url);
if (!pathDerivedPackageName.isBlank()) {
return pathDerivedPackageName;
}
Expand All@@ -58,7 +76,10 @@ public static String extractPackage(String url, String bodyText) {
String snippet = bodyText.substring(packageIndex, end);
for (String token : snippet.split("\\s+")) {
if (AsciiTextNormalizer.toLowerAscii(token).startsWith("java.")) {
return token.replaceAll("[,.;]$", "");
String candidatePackageName = token.replaceAll("[,.;]$", "");
return JavaPackageName.from(candidatePackageName)
.map(JavaPackageName::qualifiedName)
.orElse("");
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ public static String refineNestedTypeUrl(String url, String text) {
*
* @param url Javadoc page URL ending with .html
* @param text Extracted chunk text from the same page
* @param packageName Package name of the type (from metadata), can be empty
* @param packageName Java package derived from the canonical Javadoc source URL, can be empty
* @return URL with a fragment to the member if a confident match is found; original URL otherwise
*/
public static String refineMemberAnchorUrl(String url, String text, String packageName) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,7 @@ void normalizesWhitespaceWhenConstructedDirectly() {
assertEquals("of", selector.methodName());
assertEquals("List.html", selector.typePageFileName());
assertEquals("List", selector.sparseQueryTerms());
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html"));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
Expand All@@ -76,6 +76,25 @@ void rejectsFilenameShapedPseudoMethods() {
void requiresCaseSensitiveJavadocTypePageNames() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "List", "of");

assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html", null));
}

@Test
void requiresCanonicalCandidatePackageForUnqualifiedSelectors() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("", "List", "of");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", "java.util"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/class-use/List.html", "java.util.class-use"));
assertFalse(selector.matchesJavadocPath("/List.html", ""));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
void matchesQualifiedSelectorsByPathRegardlessOfCandidateMetadata() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "Date", "toString");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", null));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", "java.sql"));
assertFalse(selector.matchesJavadocPath("/java.sql/java/sql/Date.html", "java.util"));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
package com.williamcallahan.javachat.domain.javaapi;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;

/** Verifies exact Java 25 package validation and Javadoc path projection. */
class JavaPackageNameTest {

@Test
void acceptsJavaPackageNamesAndProjectsJavadocPaths() {
JavaPackageName packageName =
JavaPackageName.from("java.util.concurrent").orElseThrow();

assertEquals("java.util.concurrent", packageName.qualifiedName());
assertEquals("java/util/concurrent", packageName.javadocPath());
}

@Test
void rejectsAbsentPaddedAndInvalidPackageNames() {
List<String> invalidPackageNames = List.of(
"",
" ",
" java.util",
"java.util ",
"java..util",
"java.util.class-use",
"java.class",
"java.true",
"java.false",
"java.null");

assertTrue(JavaPackageName.from(null).isEmpty());
invalidPackageNames.forEach(candidatePackageName ->
assertTrue(JavaPackageName.from(candidatePackageName).isEmpty()));
}

@Test
void preventsDirectConstructionOfInvalidPackageNames() {
assertThrows(IllegalArgumentException.class, () -> new JavaPackageName("java.util.class-use"));
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix: prefer canonical Javadoc type pages by WilliamAGH · Pull Request #113 · WilliamAGH/java-chat · GitHub
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
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
package com.williamcallahan.javachat.application.search;

import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand DownExpand Up@@ -94,25 +95,20 @@ public String typePageFileName() {
/**
* Determines whether a Javadoc URL path identifies this selector's declaring type.
*
* <p>Unqualified selectors match the type filename in any package. Qualified selectors require
* the exact package path immediately before that filename, preventing same-named JDK types in
* different packages from receiving the same citation priority.</p>
* <p>Qualified selectors use their query package and ignore the candidate package. Unqualified
* selectors require a canonical Java package derived from the candidate source URL, which
* keeps package-relative API pages from being mistaken for canonical type pages.</p>
*
* @param javadocPath decoded Javadoc URL path
* @return true when the path names this selector's declaring type
* @param candidatePackageName package derived from the candidate source URL, or {@code null}
* when absent
* @return true when the path names this selector's declaring type in its expected package
*/
public boolean matchesJavadocPath(String javadocPath) {
public boolean matchesJavadocPath(String javadocPath, String candidatePackageName) {
Objects.requireNonNull(javadocPath, "javadocPath");
int filenameStartIndex = javadocPath.lastIndexOf('/') + 1;
String candidateFilename = javadocPath.substring(filenameStartIndex);
if (!typePageFileName().equals(candidateFilename)) {
return false;
}
if (packageName.isBlank()) {
return true;
}
String qualifiedPagePathSuffix = "/" + packageName.replace('.', '/') + "/" + typePageFileName();
return javadocPath.endsWith(qualifiedPagePathSuffix);
return expectedPackageName(candidatePackageName)
.map(expectedPackageName -> matchesPackageTypePath(javadocPath, expectedPackageName))
.orElse(false);
}

/**
Expand All@@ -124,6 +120,18 @@ public String sparseQueryTerms() {
return typePageName;
}

private Optional<JavaPackageName> expectedPackageName(String candidatePackageName) {
if (!packageName.isBlank()) {
return JavaPackageName.from(packageName);
}
return JavaPackageName.from(candidatePackageName);
}

private boolean matchesPackageTypePath(String javadocPath, JavaPackageName expectedPackageName) {
String expectedPagePath = "/" + expectedPackageName.javadocPath() + "/" + typePageFileName();
return javadocPath.endsWith(expectedPagePath);
}

private static ParsedQualifiedName parseQualifiedName(String query, int startIndex) {
List<String> segments = new ArrayList<>();
int currentIndex = startIndex;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
package com.williamcallahan.javachat.domain.javaapi;

import java.util.Optional;
import javax.lang.model.SourceVersion;

/**
* Represents an exact, syntactically valid Java 25 package name.
*
* <p>The value preserves its source spelling so URL projections and query selectors share one
* package-identity boundary without trimming or accepting documentation-only path segments.</p>
*
* @param qualifiedName dot-separated Java package name
*/
public record JavaPackageName(String qualifiedName) {

/**
* Enforces exact Java 25 package-name syntax for every constructed value.
*
* @throws IllegalArgumentException when the package name is null, blank, padded, or invalid
*/
public JavaPackageName {
if (!isValid(qualifiedName)) {
throw new IllegalArgumentException("qualifiedName must be an exact Java 25 package name");
}
}

/**
* Creates a package name only when the candidate satisfies the Java 25 name grammar exactly.
*
* @param candidateQualifiedName candidate dot-separated package name
* @return validated package name, or empty when the candidate is absent or invalid
*/
public static Optional<JavaPackageName> from(String candidateQualifiedName) {
return isValid(candidateQualifiedName)
? Optional.of(new JavaPackageName(candidateQualifiedName))
: Optional.empty();
}

/**
* Projects the package into the path syntax used by canonical Javadoc URLs.
*
* @return slash-separated package path without leading or trailing delimiters
*/
public String javadocPath() {
return qualifiedName.replace('.', '/');
}

private static boolean isValid(String candidateQualifiedName) {
return candidateQualifiedName != null
&& !candidateQualifiedName.isBlank()
&& candidateQualifiedName.equals(candidateQualifiedName.trim())
&& SourceVersion.isName(candidateQualifiedName, SourceVersion.RELEASE_25);
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.application.search.JavaApiMethodSelector;
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
Expand DownExpand Up@@ -86,7 +87,8 @@ private static boolean matchesTypePage(JavaApiMethodSelector selector, Document
if (documentPath == null || documentPath.isBlank()) {
return false;
}
return selector.matchesJavadocPath(documentPath);
String candidatePackageName = JavaPackageExtractor.extractJavaApiPackage(sourceUrl);
return selector.matchesJavadocPath(documentPath, candidatePackageName);
}

private static boolean hasMethodDeclarationEvidence(JavaApiMethodSelector selector, Document citationCandidate) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.ModelConfiguration;
import com.williamcallahan.javachat.model.Citation;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor.VersionFilterPatterns;
import java.util.ArrayList;
Expand DownExpand Up@@ -415,10 +416,8 @@ public CitationOutcome toCitations(List<Document> documents) {
Map<String, ?> sourceDocMetadata = sourceDocument.getMetadata();
String rawUrl = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.URL_FIELD);
String title = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.TITLE_FIELD);
String packageName = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.PACKAGE_FIELD);
String documentType = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.DOC_TYPE_FIELD);
String refinedCitationUrl =
refineCitationUrl(rawUrl, sourceDocument.getText(), packageName, documentType);
String refinedCitationUrl = refineCitationUrl(rawUrl, sourceDocument.getText(), documentType);
String citationIdentity = citationIdentityFor(rawUrl, refinedCitationUrl);
if (!citationIdentity.isBlank() && !retainedCitationIdentities.add(citationIdentity)) {
continue;
Expand DownExpand Up@@ -478,10 +477,11 @@ private static String citationIdentityFor(String rawUrl, String citationUrl) {
* Refines a raw document URL and gates Javadoc member anchors to {@code api-docs} metadata.
*
*/
private String refineCitationUrl(String rawUrl, String documentText, String packageName, String documentType) {
private String refineCitationUrl(String rawUrl, String documentText, String documentType) {
String normalizedUrl = DocsSourceRegistry.normalizeDocUrl(rawUrl);
String citationUrl = normalizedUrl;
if (DOCUMENT_TYPE_API_DOCS.equals(documentType)) {
String packageName = JavaPackageExtractor.extractJavaApiPackage(normalizedUrl);
String nestedTypeRefinedUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineNestedTypeUrl(
citationUrl, documentText);
citationUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineMemberAnchorUrl(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource;
import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import com.williamcallahan.javachat.support.AsciiTextNormalizer;
import java.net.URI;
import java.net.URISyntaxException;
Expand DownExpand Up@@ -34,6 +35,25 @@ public static boolean isJavaApiUrl(String url) {
return findJavaApiSourceUrl(url).isPresent();
}

/**
* Derives the Java package encoded by a manifest-governed Java API source URL.
*
* <p>URL consumers use this projection instead of persisted package metadata so package
* identity follows the canonical source path across ingestion generations.</p>
*
* @param url source URL
* @return package encoded by the canonical Java API path, or an empty string when the URL is
* not a canonical Java API source or its path does not identify a package
*/
public static String extractJavaApiPackage(String url) {
Objects.requireNonNull(url, "url");
return findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.flatMap(JavaPackageName::from)
.map(JavaPackageName::qualifiedName)
.orElse("");
}

/**
* Attempts to derive a package name from the URL and extracted page text.
*
Expand All@@ -45,9 +65,7 @@ public static String extractPackage(String url, String bodyText) {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(bodyText, "bodyText");

String pathDerivedPackageName = findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.orElse("");
String pathDerivedPackageName = extractJavaApiPackage(url);
if (!pathDerivedPackageName.isBlank()) {
return pathDerivedPackageName;
}
Expand All@@ -58,7 +76,10 @@ public static String extractPackage(String url, String bodyText) {
String snippet = bodyText.substring(packageIndex, end);
for (String token : snippet.split("\\s+")) {
if (AsciiTextNormalizer.toLowerAscii(token).startsWith("java.")) {
return token.replaceAll("[,.;]$", "");
String candidatePackageName = token.replaceAll("[,.;]$", "");
return JavaPackageName.from(candidatePackageName)
.map(JavaPackageName::qualifiedName)
.orElse("");
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ public static String refineNestedTypeUrl(String url, String text) {
*
* @param url Javadoc page URL ending with .html
* @param text Extracted chunk text from the same page
* @param packageName Package name of the type (from metadata), can be empty
* @param packageName Java package derived from the canonical Javadoc source URL, can be empty
* @return URL with a fragment to the member if a confident match is found; original URL otherwise
*/
public static String refineMemberAnchorUrl(String url, String text, String packageName) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,7 @@ void normalizesWhitespaceWhenConstructedDirectly() {
assertEquals("of", selector.methodName());
assertEquals("List.html", selector.typePageFileName());
assertEquals("List", selector.sparseQueryTerms());
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html"));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
Expand All@@ -76,6 +76,25 @@ void rejectsFilenameShapedPseudoMethods() {
void requiresCaseSensitiveJavadocTypePageNames() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "List", "of");

assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html", null));
}

@Test
void requiresCanonicalCandidatePackageForUnqualifiedSelectors() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("", "List", "of");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", "java.util"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/class-use/List.html", "java.util.class-use"));
assertFalse(selector.matchesJavadocPath("/List.html", ""));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
void matchesQualifiedSelectorsByPathRegardlessOfCandidateMetadata() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "Date", "toString");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", null));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", "java.sql"));
assertFalse(selector.matchesJavadocPath("/java.sql/java/sql/Date.html", "java.util"));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
package com.williamcallahan.javachat.domain.javaapi;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;

/** Verifies exact Java 25 package validation and Javadoc path projection. */
class JavaPackageNameTest {

@Test
void acceptsJavaPackageNamesAndProjectsJavadocPaths() {
JavaPackageName packageName =
JavaPackageName.from("java.util.concurrent").orElseThrow();

assertEquals("java.util.concurrent", packageName.qualifiedName());
assertEquals("java/util/concurrent", packageName.javadocPath());
}

@Test
void rejectsAbsentPaddedAndInvalidPackageNames() {
List<String> invalidPackageNames = List.of(
"",
" ",
" java.util",
"java.util ",
"java..util",
"java.util.class-use",
"java.class",
"java.true",
"java.false",
"java.null");

assertTrue(JavaPackageName.from(null).isEmpty());
invalidPackageNames.forEach(candidatePackageName ->
assertTrue(JavaPackageName.from(candidatePackageName).isEmpty()));
}

@Test
void preventsDirectConstructionOfInvalidPackageNames() {
assertThrows(IllegalArgumentException.class, () -> new JavaPackageName("java.util.class-use"));
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: prefer canonical Javadoc type pages by WilliamAGH · Pull Request #113 · WilliamAGH/java-chat · GitHub
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
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
package com.williamcallahan.javachat.application.search;

import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand DownExpand Up@@ -94,25 +95,20 @@ public String typePageFileName() {
/**
* Determines whether a Javadoc URL path identifies this selector's declaring type.
*
* <p>Unqualified selectors match the type filename in any package. Qualified selectors require
* the exact package path immediately before that filename, preventing same-named JDK types in
* different packages from receiving the same citation priority.</p>
* <p>Qualified selectors use their query package and ignore the candidate package. Unqualified
* selectors require a canonical Java package derived from the candidate source URL, which
* keeps package-relative API pages from being mistaken for canonical type pages.</p>
*
* @param javadocPath decoded Javadoc URL path
* @return true when the path names this selector's declaring type
* @param candidatePackageName package derived from the candidate source URL, or {@code null}
* when absent
* @return true when the path names this selector's declaring type in its expected package
*/
public boolean matchesJavadocPath(String javadocPath) {
public boolean matchesJavadocPath(String javadocPath, String candidatePackageName) {
Objects.requireNonNull(javadocPath, "javadocPath");
int filenameStartIndex = javadocPath.lastIndexOf('/') + 1;
String candidateFilename = javadocPath.substring(filenameStartIndex);
if (!typePageFileName().equals(candidateFilename)) {
return false;
}
if (packageName.isBlank()) {
return true;
}
String qualifiedPagePathSuffix = "/" + packageName.replace('.', '/') + "/" + typePageFileName();
return javadocPath.endsWith(qualifiedPagePathSuffix);
return expectedPackageName(candidatePackageName)
.map(expectedPackageName -> matchesPackageTypePath(javadocPath, expectedPackageName))
.orElse(false);
}

/**
Expand All@@ -124,6 +120,18 @@ public String sparseQueryTerms() {
return typePageName;
}

private Optional<JavaPackageName> expectedPackageName(String candidatePackageName) {
if (!packageName.isBlank()) {
return JavaPackageName.from(packageName);
}
return JavaPackageName.from(candidatePackageName);
}

private boolean matchesPackageTypePath(String javadocPath, JavaPackageName expectedPackageName) {
String expectedPagePath = "/" + expectedPackageName.javadocPath() + "/" + typePageFileName();
return javadocPath.endsWith(expectedPagePath);
}

private static ParsedQualifiedName parseQualifiedName(String query, int startIndex) {
List<String> segments = new ArrayList<>();
int currentIndex = startIndex;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
package com.williamcallahan.javachat.domain.javaapi;

import java.util.Optional;
import javax.lang.model.SourceVersion;

/**
* Represents an exact, syntactically valid Java 25 package name.
*
* <p>The value preserves its source spelling so URL projections and query selectors share one
* package-identity boundary without trimming or accepting documentation-only path segments.</p>
*
* @param qualifiedName dot-separated Java package name
*/
public record JavaPackageName(String qualifiedName) {

/**
* Enforces exact Java 25 package-name syntax for every constructed value.
*
* @throws IllegalArgumentException when the package name is null, blank, padded, or invalid
*/
public JavaPackageName {
if (!isValid(qualifiedName)) {
throw new IllegalArgumentException("qualifiedName must be an exact Java 25 package name");
}
}

/**
* Creates a package name only when the candidate satisfies the Java 25 name grammar exactly.
*
* @param candidateQualifiedName candidate dot-separated package name
* @return validated package name, or empty when the candidate is absent or invalid
*/
public static Optional<JavaPackageName> from(String candidateQualifiedName) {
return isValid(candidateQualifiedName)
? Optional.of(new JavaPackageName(candidateQualifiedName))
: Optional.empty();
}

/**
* Projects the package into the path syntax used by canonical Javadoc URLs.
*
* @return slash-separated package path without leading or trailing delimiters
*/
public String javadocPath() {
return qualifiedName.replace('.', '/');
}

private static boolean isValid(String candidateQualifiedName) {
return candidateQualifiedName != null
&& !candidateQualifiedName.isBlank()
&& candidateQualifiedName.equals(candidateQualifiedName.trim())
&& SourceVersion.isName(candidateQualifiedName, SourceVersion.RELEASE_25);
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.application.search.JavaApiMethodSelector;
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
Expand DownExpand Up@@ -86,7 +87,8 @@ private static boolean matchesTypePage(JavaApiMethodSelector selector, Document
if (documentPath == null || documentPath.isBlank()) {
return false;
}
return selector.matchesJavadocPath(documentPath);
String candidatePackageName = JavaPackageExtractor.extractJavaApiPackage(sourceUrl);
return selector.matchesJavadocPath(documentPath, candidatePackageName);
}

private static boolean hasMethodDeclarationEvidence(JavaApiMethodSelector selector, Document citationCandidate) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.ModelConfiguration;
import com.williamcallahan.javachat.model.Citation;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor.VersionFilterPatterns;
import java.util.ArrayList;
Expand DownExpand Up@@ -415,10 +416,8 @@ public CitationOutcome toCitations(List<Document> documents) {
Map<String, ?> sourceDocMetadata = sourceDocument.getMetadata();
String rawUrl = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.URL_FIELD);
String title = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.TITLE_FIELD);
String packageName = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.PACKAGE_FIELD);
String documentType = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.DOC_TYPE_FIELD);
String refinedCitationUrl =
refineCitationUrl(rawUrl, sourceDocument.getText(), packageName, documentType);
String refinedCitationUrl = refineCitationUrl(rawUrl, sourceDocument.getText(), documentType);
String citationIdentity = citationIdentityFor(rawUrl, refinedCitationUrl);
if (!citationIdentity.isBlank() && !retainedCitationIdentities.add(citationIdentity)) {
continue;
Expand DownExpand Up@@ -478,10 +477,11 @@ private static String citationIdentityFor(String rawUrl, String citationUrl) {
* Refines a raw document URL and gates Javadoc member anchors to {@code api-docs} metadata.
*
*/
private String refineCitationUrl(String rawUrl, String documentText, String packageName, String documentType) {
private String refineCitationUrl(String rawUrl, String documentText, String documentType) {
String normalizedUrl = DocsSourceRegistry.normalizeDocUrl(rawUrl);
String citationUrl = normalizedUrl;
if (DOCUMENT_TYPE_API_DOCS.equals(documentType)) {
String packageName = JavaPackageExtractor.extractJavaApiPackage(normalizedUrl);
String nestedTypeRefinedUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineNestedTypeUrl(
citationUrl, documentText);
citationUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineMemberAnchorUrl(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource;
import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import com.williamcallahan.javachat.support.AsciiTextNormalizer;
import java.net.URI;
import java.net.URISyntaxException;
Expand DownExpand Up@@ -34,6 +35,25 @@ public static boolean isJavaApiUrl(String url) {
return findJavaApiSourceUrl(url).isPresent();
}

/**
* Derives the Java package encoded by a manifest-governed Java API source URL.
*
* <p>URL consumers use this projection instead of persisted package metadata so package
* identity follows the canonical source path across ingestion generations.</p>
*
* @param url source URL
* @return package encoded by the canonical Java API path, or an empty string when the URL is
* not a canonical Java API source or its path does not identify a package
*/
public static String extractJavaApiPackage(String url) {
Objects.requireNonNull(url, "url");
return findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.flatMap(JavaPackageName::from)
.map(JavaPackageName::qualifiedName)
.orElse("");
}

/**
* Attempts to derive a package name from the URL and extracted page text.
*
Expand All@@ -45,9 +65,7 @@ public static String extractPackage(String url, String bodyText) {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(bodyText, "bodyText");

String pathDerivedPackageName = findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.orElse("");
String pathDerivedPackageName = extractJavaApiPackage(url);
if (!pathDerivedPackageName.isBlank()) {
return pathDerivedPackageName;
}
Expand All@@ -58,7 +76,10 @@ public static String extractPackage(String url, String bodyText) {
String snippet = bodyText.substring(packageIndex, end);
for (String token : snippet.split("\\s+")) {
if (AsciiTextNormalizer.toLowerAscii(token).startsWith("java.")) {
return token.replaceAll("[,.;]$", "");
String candidatePackageName = token.replaceAll("[,.;]$", "");
return JavaPackageName.from(candidatePackageName)
.map(JavaPackageName::qualifiedName)
.orElse("");
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ public static String refineNestedTypeUrl(String url, String text) {
*
* @param url Javadoc page URL ending with .html
* @param text Extracted chunk text from the same page
* @param packageName Package name of the type (from metadata), can be empty
* @param packageName Java package derived from the canonical Javadoc source URL, can be empty
* @return URL with a fragment to the member if a confident match is found; original URL otherwise
*/
public static String refineMemberAnchorUrl(String url, String text, String packageName) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,7 @@ void normalizesWhitespaceWhenConstructedDirectly() {
assertEquals("of", selector.methodName());
assertEquals("List.html", selector.typePageFileName());
assertEquals("List", selector.sparseQueryTerms());
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html"));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
Expand All@@ -76,6 +76,25 @@ void rejectsFilenameShapedPseudoMethods() {
void requiresCaseSensitiveJavadocTypePageNames() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "List", "of");

assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html", null));
}

@Test
void requiresCanonicalCandidatePackageForUnqualifiedSelectors() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("", "List", "of");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", "java.util"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/class-use/List.html", "java.util.class-use"));
assertFalse(selector.matchesJavadocPath("/List.html", ""));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
void matchesQualifiedSelectorsByPathRegardlessOfCandidateMetadata() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "Date", "toString");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", null));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", "java.sql"));
assertFalse(selector.matchesJavadocPath("/java.sql/java/sql/Date.html", "java.util"));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
package com.williamcallahan.javachat.domain.javaapi;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;

/** Verifies exact Java 25 package validation and Javadoc path projection. */
class JavaPackageNameTest {

@Test
void acceptsJavaPackageNamesAndProjectsJavadocPaths() {
JavaPackageName packageName =
JavaPackageName.from("java.util.concurrent").orElseThrow();

assertEquals("java.util.concurrent", packageName.qualifiedName());
assertEquals("java/util/concurrent", packageName.javadocPath());
}

@Test
void rejectsAbsentPaddedAndInvalidPackageNames() {
List<String> invalidPackageNames = List.of(
"",
" ",
" java.util",
"java.util ",
"java..util",
"java.util.class-use",
"java.class",
"java.true",
"java.false",
"java.null");

assertTrue(JavaPackageName.from(null).isEmpty());
invalidPackageNames.forEach(candidatePackageName ->
assertTrue(JavaPackageName.from(candidatePackageName).isEmpty()));
}

@Test
void preventsDirectConstructionOfInvalidPackageNames() {
assertThrows(IllegalArgumentException.class, () -> new JavaPackageName("java.util.class-use"));
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: prefer canonical Javadoc type pages by WilliamAGH · Pull Request #113 · WilliamAGH/java-chat · GitHub
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
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
package com.williamcallahan.javachat.application.search;

import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand DownExpand Up@@ -94,25 +95,20 @@ public String typePageFileName() {
/**
* Determines whether a Javadoc URL path identifies this selector's declaring type.
*
* <p>Unqualified selectors match the type filename in any package. Qualified selectors require
* the exact package path immediately before that filename, preventing same-named JDK types in
* different packages from receiving the same citation priority.</p>
* <p>Qualified selectors use their query package and ignore the candidate package. Unqualified
* selectors require a canonical Java package derived from the candidate source URL, which
* keeps package-relative API pages from being mistaken for canonical type pages.</p>
*
* @param javadocPath decoded Javadoc URL path
* @return true when the path names this selector's declaring type
* @param candidatePackageName package derived from the candidate source URL, or {@code null}
* when absent
* @return true when the path names this selector's declaring type in its expected package
*/
public boolean matchesJavadocPath(String javadocPath) {
public boolean matchesJavadocPath(String javadocPath, String candidatePackageName) {
Objects.requireNonNull(javadocPath, "javadocPath");
int filenameStartIndex = javadocPath.lastIndexOf('/') + 1;
String candidateFilename = javadocPath.substring(filenameStartIndex);
if (!typePageFileName().equals(candidateFilename)) {
return false;
}
if (packageName.isBlank()) {
return true;
}
String qualifiedPagePathSuffix = "/" + packageName.replace('.', '/') + "/" + typePageFileName();
return javadocPath.endsWith(qualifiedPagePathSuffix);
return expectedPackageName(candidatePackageName)
.map(expectedPackageName -> matchesPackageTypePath(javadocPath, expectedPackageName))
.orElse(false);
}

/**
Expand All@@ -124,6 +120,18 @@ public String sparseQueryTerms() {
return typePageName;
}

private Optional<JavaPackageName> expectedPackageName(String candidatePackageName) {
if (!packageName.isBlank()) {
return JavaPackageName.from(packageName);
}
return JavaPackageName.from(candidatePackageName);
}

private boolean matchesPackageTypePath(String javadocPath, JavaPackageName expectedPackageName) {
String expectedPagePath = "/" + expectedPackageName.javadocPath() + "/" + typePageFileName();
return javadocPath.endsWith(expectedPagePath);
}

private static ParsedQualifiedName parseQualifiedName(String query, int startIndex) {
List<String> segments = new ArrayList<>();
int currentIndex = startIndex;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
package com.williamcallahan.javachat.domain.javaapi;

import java.util.Optional;
import javax.lang.model.SourceVersion;

/**
* Represents an exact, syntactically valid Java 25 package name.
*
* <p>The value preserves its source spelling so URL projections and query selectors share one
* package-identity boundary without trimming or accepting documentation-only path segments.</p>
*
* @param qualifiedName dot-separated Java package name
*/
public record JavaPackageName(String qualifiedName) {

/**
* Enforces exact Java 25 package-name syntax for every constructed value.
*
* @throws IllegalArgumentException when the package name is null, blank, padded, or invalid
*/
public JavaPackageName {
if (!isValid(qualifiedName)) {
throw new IllegalArgumentException("qualifiedName must be an exact Java 25 package name");
}
}

/**
* Creates a package name only when the candidate satisfies the Java 25 name grammar exactly.
*
* @param candidateQualifiedName candidate dot-separated package name
* @return validated package name, or empty when the candidate is absent or invalid
*/
public static Optional<JavaPackageName> from(String candidateQualifiedName) {
return isValid(candidateQualifiedName)
? Optional.of(new JavaPackageName(candidateQualifiedName))
: Optional.empty();
}

/**
* Projects the package into the path syntax used by canonical Javadoc URLs.
*
* @return slash-separated package path without leading or trailing delimiters
*/
public String javadocPath() {
return qualifiedName.replace('.', '/');
}

private static boolean isValid(String candidateQualifiedName) {
return candidateQualifiedName != null
&& !candidateQualifiedName.isBlank()
&& candidateQualifiedName.equals(candidateQualifiedName.trim())
&& SourceVersion.isName(candidateQualifiedName, SourceVersion.RELEASE_25);
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.application.search.JavaApiMethodSelector;
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
Expand DownExpand Up@@ -86,7 +87,8 @@ private static boolean matchesTypePage(JavaApiMethodSelector selector, Document
if (documentPath == null || documentPath.isBlank()) {
return false;
}
return selector.matchesJavadocPath(documentPath);
String candidatePackageName = JavaPackageExtractor.extractJavaApiPackage(sourceUrl);
return selector.matchesJavadocPath(documentPath, candidatePackageName);
}

private static boolean hasMethodDeclarationEvidence(JavaApiMethodSelector selector, Document citationCandidate) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.ModelConfiguration;
import com.williamcallahan.javachat.model.Citation;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor.VersionFilterPatterns;
import java.util.ArrayList;
Expand DownExpand Up@@ -415,10 +416,8 @@ public CitationOutcome toCitations(List<Document> documents) {
Map<String, ?> sourceDocMetadata = sourceDocument.getMetadata();
String rawUrl = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.URL_FIELD);
String title = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.TITLE_FIELD);
String packageName = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.PACKAGE_FIELD);
String documentType = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.DOC_TYPE_FIELD);
String refinedCitationUrl =
refineCitationUrl(rawUrl, sourceDocument.getText(), packageName, documentType);
String refinedCitationUrl = refineCitationUrl(rawUrl, sourceDocument.getText(), documentType);
String citationIdentity = citationIdentityFor(rawUrl, refinedCitationUrl);
if (!citationIdentity.isBlank() && !retainedCitationIdentities.add(citationIdentity)) {
continue;
Expand DownExpand Up@@ -478,10 +477,11 @@ private static String citationIdentityFor(String rawUrl, String citationUrl) {
* Refines a raw document URL and gates Javadoc member anchors to {@code api-docs} metadata.
*
*/
private String refineCitationUrl(String rawUrl, String documentText, String packageName, String documentType) {
private String refineCitationUrl(String rawUrl, String documentText, String documentType) {
String normalizedUrl = DocsSourceRegistry.normalizeDocUrl(rawUrl);
String citationUrl = normalizedUrl;
if (DOCUMENT_TYPE_API_DOCS.equals(documentType)) {
String packageName = JavaPackageExtractor.extractJavaApiPackage(normalizedUrl);
String nestedTypeRefinedUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineNestedTypeUrl(
citationUrl, documentText);
citationUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineMemberAnchorUrl(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource;
import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import com.williamcallahan.javachat.support.AsciiTextNormalizer;
import java.net.URI;
import java.net.URISyntaxException;
Expand DownExpand Up@@ -34,6 +35,25 @@ public static boolean isJavaApiUrl(String url) {
return findJavaApiSourceUrl(url).isPresent();
}

/**
* Derives the Java package encoded by a manifest-governed Java API source URL.
*
* <p>URL consumers use this projection instead of persisted package metadata so package
* identity follows the canonical source path across ingestion generations.</p>
*
* @param url source URL
* @return package encoded by the canonical Java API path, or an empty string when the URL is
* not a canonical Java API source or its path does not identify a package
*/
public static String extractJavaApiPackage(String url) {
Objects.requireNonNull(url, "url");
return findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.flatMap(JavaPackageName::from)
.map(JavaPackageName::qualifiedName)
.orElse("");
}

/**
* Attempts to derive a package name from the URL and extracted page text.
*
Expand All@@ -45,9 +65,7 @@ public static String extractPackage(String url, String bodyText) {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(bodyText, "bodyText");

String pathDerivedPackageName = findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.orElse("");
String pathDerivedPackageName = extractJavaApiPackage(url);
if (!pathDerivedPackageName.isBlank()) {
return pathDerivedPackageName;
}
Expand All@@ -58,7 +76,10 @@ public static String extractPackage(String url, String bodyText) {
String snippet = bodyText.substring(packageIndex, end);
for (String token : snippet.split("\\s+")) {
if (AsciiTextNormalizer.toLowerAscii(token).startsWith("java.")) {
return token.replaceAll("[,.;]$", "");
String candidatePackageName = token.replaceAll("[,.;]$", "");
return JavaPackageName.from(candidatePackageName)
.map(JavaPackageName::qualifiedName)
.orElse("");
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ public static String refineNestedTypeUrl(String url, String text) {
*
* @param url Javadoc page URL ending with .html
* @param text Extracted chunk text from the same page
* @param packageName Package name of the type (from metadata), can be empty
* @param packageName Java package derived from the canonical Javadoc source URL, can be empty
* @return URL with a fragment to the member if a confident match is found; original URL otherwise
*/
public static String refineMemberAnchorUrl(String url, String text, String packageName) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,7 @@ void normalizesWhitespaceWhenConstructedDirectly() {
assertEquals("of", selector.methodName());
assertEquals("List.html", selector.typePageFileName());
assertEquals("List", selector.sparseQueryTerms());
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html"));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
Expand All@@ -76,6 +76,25 @@ void rejectsFilenameShapedPseudoMethods() {
void requiresCaseSensitiveJavadocTypePageNames() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "List", "of");

assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html", null));
}

@Test
void requiresCanonicalCandidatePackageForUnqualifiedSelectors() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("", "List", "of");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", "java.util"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/class-use/List.html", "java.util.class-use"));
assertFalse(selector.matchesJavadocPath("/List.html", ""));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
void matchesQualifiedSelectorsByPathRegardlessOfCandidateMetadata() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "Date", "toString");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", null));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", "java.sql"));
assertFalse(selector.matchesJavadocPath("/java.sql/java/sql/Date.html", "java.util"));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
package com.williamcallahan.javachat.domain.javaapi;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;

/** Verifies exact Java 25 package validation and Javadoc path projection. */
class JavaPackageNameTest {

@Test
void acceptsJavaPackageNamesAndProjectsJavadocPaths() {
JavaPackageName packageName =
JavaPackageName.from("java.util.concurrent").orElseThrow();

assertEquals("java.util.concurrent", packageName.qualifiedName());
assertEquals("java/util/concurrent", packageName.javadocPath());
}

@Test
void rejectsAbsentPaddedAndInvalidPackageNames() {
List<String> invalidPackageNames = List.of(
"",
" ",
" java.util",
"java.util ",
"java..util",
"java.util.class-use",
"java.class",
"java.true",
"java.false",
"java.null");

assertTrue(JavaPackageName.from(null).isEmpty());
invalidPackageNames.forEach(candidatePackageName ->
assertTrue(JavaPackageName.from(candidatePackageName).isEmpty()));
}

@Test
void preventsDirectConstructionOfInvalidPackageNames() {
assertThrows(IllegalArgumentException.class, () -> new JavaPackageName("java.util.class-use"));
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix: prefer canonical Javadoc type pages by WilliamAGH · Pull Request #113 · WilliamAGH/java-chat · GitHub
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
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
package com.williamcallahan.javachat.application.search;

import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand DownExpand Up@@ -94,25 +95,20 @@ public String typePageFileName() {
/**
* Determines whether a Javadoc URL path identifies this selector's declaring type.
*
* <p>Unqualified selectors match the type filename in any package. Qualified selectors require
* the exact package path immediately before that filename, preventing same-named JDK types in
* different packages from receiving the same citation priority.</p>
* <p>Qualified selectors use their query package and ignore the candidate package. Unqualified
* selectors require a canonical Java package derived from the candidate source URL, which
* keeps package-relative API pages from being mistaken for canonical type pages.</p>
*
* @param javadocPath decoded Javadoc URL path
* @return true when the path names this selector's declaring type
* @param candidatePackageName package derived from the candidate source URL, or {@code null}
* when absent
* @return true when the path names this selector's declaring type in its expected package
*/
public boolean matchesJavadocPath(String javadocPath) {
public boolean matchesJavadocPath(String javadocPath, String candidatePackageName) {
Objects.requireNonNull(javadocPath, "javadocPath");
int filenameStartIndex = javadocPath.lastIndexOf('/') + 1;
String candidateFilename = javadocPath.substring(filenameStartIndex);
if (!typePageFileName().equals(candidateFilename)) {
return false;
}
if (packageName.isBlank()) {
return true;
}
String qualifiedPagePathSuffix = "/" + packageName.replace('.', '/') + "/" + typePageFileName();
return javadocPath.endsWith(qualifiedPagePathSuffix);
return expectedPackageName(candidatePackageName)
.map(expectedPackageName -> matchesPackageTypePath(javadocPath, expectedPackageName))
.orElse(false);
}

/**
Expand All@@ -124,6 +120,18 @@ public String sparseQueryTerms() {
return typePageName;
}

private Optional<JavaPackageName> expectedPackageName(String candidatePackageName) {
if (!packageName.isBlank()) {
return JavaPackageName.from(packageName);
}
return JavaPackageName.from(candidatePackageName);
}

private boolean matchesPackageTypePath(String javadocPath, JavaPackageName expectedPackageName) {
String expectedPagePath = "/" + expectedPackageName.javadocPath() + "/" + typePageFileName();
return javadocPath.endsWith(expectedPagePath);
}

private static ParsedQualifiedName parseQualifiedName(String query, int startIndex) {
List<String> segments = new ArrayList<>();
int currentIndex = startIndex;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
package com.williamcallahan.javachat.domain.javaapi;

import java.util.Optional;
import javax.lang.model.SourceVersion;

/**
* Represents an exact, syntactically valid Java 25 package name.
*
* <p>The value preserves its source spelling so URL projections and query selectors share one
* package-identity boundary without trimming or accepting documentation-only path segments.</p>
*
* @param qualifiedName dot-separated Java package name
*/
public record JavaPackageName(String qualifiedName) {

/**
* Enforces exact Java 25 package-name syntax for every constructed value.
*
* @throws IllegalArgumentException when the package name is null, blank, padded, or invalid
*/
public JavaPackageName {
if (!isValid(qualifiedName)) {
throw new IllegalArgumentException("qualifiedName must be an exact Java 25 package name");
}
}

/**
* Creates a package name only when the candidate satisfies the Java 25 name grammar exactly.
*
* @param candidateQualifiedName candidate dot-separated package name
* @return validated package name, or empty when the candidate is absent or invalid
*/
public static Optional<JavaPackageName> from(String candidateQualifiedName) {
return isValid(candidateQualifiedName)
? Optional.of(new JavaPackageName(candidateQualifiedName))
: Optional.empty();
}

/**
* Projects the package into the path syntax used by canonical Javadoc URLs.
*
* @return slash-separated package path without leading or trailing delimiters
*/
public String javadocPath() {
return qualifiedName.replace('.', '/');
}

private static boolean isValid(String candidateQualifiedName) {
return candidateQualifiedName != null
&& !candidateQualifiedName.isBlank()
&& candidateQualifiedName.equals(candidateQualifiedName.trim())
&& SourceVersion.isName(candidateQualifiedName, SourceVersion.RELEASE_25);
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.application.search.JavaApiMethodSelector;
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
Expand DownExpand Up@@ -86,7 +87,8 @@ private static boolean matchesTypePage(JavaApiMethodSelector selector, Document
if (documentPath == null || documentPath.isBlank()) {
return false;
}
return selector.matchesJavadocPath(documentPath);
String candidatePackageName = JavaPackageExtractor.extractJavaApiPackage(sourceUrl);
return selector.matchesJavadocPath(documentPath, candidatePackageName);
}

private static boolean hasMethodDeclarationEvidence(JavaApiMethodSelector selector, Document citationCandidate) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.ModelConfiguration;
import com.williamcallahan.javachat.model.Citation;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor.VersionFilterPatterns;
import java.util.ArrayList;
Expand DownExpand Up@@ -415,10 +416,8 @@ public CitationOutcome toCitations(List<Document> documents) {
Map<String, ?> sourceDocMetadata = sourceDocument.getMetadata();
String rawUrl = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.URL_FIELD);
String title = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.TITLE_FIELD);
String packageName = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.PACKAGE_FIELD);
String documentType = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.DOC_TYPE_FIELD);
String refinedCitationUrl =
refineCitationUrl(rawUrl, sourceDocument.getText(), packageName, documentType);
String refinedCitationUrl = refineCitationUrl(rawUrl, sourceDocument.getText(), documentType);
String citationIdentity = citationIdentityFor(rawUrl, refinedCitationUrl);
if (!citationIdentity.isBlank() && !retainedCitationIdentities.add(citationIdentity)) {
continue;
Expand DownExpand Up@@ -478,10 +477,11 @@ private static String citationIdentityFor(String rawUrl, String citationUrl) {
* Refines a raw document URL and gates Javadoc member anchors to {@code api-docs} metadata.
*
*/
private String refineCitationUrl(String rawUrl, String documentText, String packageName, String documentType) {
private String refineCitationUrl(String rawUrl, String documentText, String documentType) {
String normalizedUrl = DocsSourceRegistry.normalizeDocUrl(rawUrl);
String citationUrl = normalizedUrl;
if (DOCUMENT_TYPE_API_DOCS.equals(documentType)) {
String packageName = JavaPackageExtractor.extractJavaApiPackage(normalizedUrl);
String nestedTypeRefinedUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineNestedTypeUrl(
citationUrl, documentText);
citationUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineMemberAnchorUrl(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource;
import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import com.williamcallahan.javachat.support.AsciiTextNormalizer;
import java.net.URI;
import java.net.URISyntaxException;
Expand DownExpand Up@@ -34,6 +35,25 @@ public static boolean isJavaApiUrl(String url) {
return findJavaApiSourceUrl(url).isPresent();
}

/**
* Derives the Java package encoded by a manifest-governed Java API source URL.
*
* <p>URL consumers use this projection instead of persisted package metadata so package
* identity follows the canonical source path across ingestion generations.</p>
*
* @param url source URL
* @return package encoded by the canonical Java API path, or an empty string when the URL is
* not a canonical Java API source or its path does not identify a package
*/
public static String extractJavaApiPackage(String url) {
Objects.requireNonNull(url, "url");
return findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.flatMap(JavaPackageName::from)
.map(JavaPackageName::qualifiedName)
.orElse("");
}

/**
* Attempts to derive a package name from the URL and extracted page text.
*
Expand All@@ -45,9 +65,7 @@ public static String extractPackage(String url, String bodyText) {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(bodyText, "bodyText");

String pathDerivedPackageName = findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.orElse("");
String pathDerivedPackageName = extractJavaApiPackage(url);
if (!pathDerivedPackageName.isBlank()) {
return pathDerivedPackageName;
}
Expand All@@ -58,7 +76,10 @@ public static String extractPackage(String url, String bodyText) {
String snippet = bodyText.substring(packageIndex, end);
for (String token : snippet.split("\\s+")) {
if (AsciiTextNormalizer.toLowerAscii(token).startsWith("java.")) {
return token.replaceAll("[,.;]$", "");
String candidatePackageName = token.replaceAll("[,.;]$", "");
return JavaPackageName.from(candidatePackageName)
.map(JavaPackageName::qualifiedName)
.orElse("");
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ public static String refineNestedTypeUrl(String url, String text) {
*
* @param url Javadoc page URL ending with .html
* @param text Extracted chunk text from the same page
* @param packageName Package name of the type (from metadata), can be empty
* @param packageName Java package derived from the canonical Javadoc source URL, can be empty
* @return URL with a fragment to the member if a confident match is found; original URL otherwise
*/
public static String refineMemberAnchorUrl(String url, String text, String packageName) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,7 @@ void normalizesWhitespaceWhenConstructedDirectly() {
assertEquals("of", selector.methodName());
assertEquals("List.html", selector.typePageFileName());
assertEquals("List", selector.sparseQueryTerms());
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html"));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
Expand All@@ -76,6 +76,25 @@ void rejectsFilenameShapedPseudoMethods() {
void requiresCaseSensitiveJavadocTypePageNames() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "List", "of");

assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html", null));
}

@Test
void requiresCanonicalCandidatePackageForUnqualifiedSelectors() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("", "List", "of");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", "java.util"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/class-use/List.html", "java.util.class-use"));
assertFalse(selector.matchesJavadocPath("/List.html", ""));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
void matchesQualifiedSelectorsByPathRegardlessOfCandidateMetadata() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "Date", "toString");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", null));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", "java.sql"));
assertFalse(selector.matchesJavadocPath("/java.sql/java/sql/Date.html", "java.util"));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
package com.williamcallahan.javachat.domain.javaapi;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;

/** Verifies exact Java 25 package validation and Javadoc path projection. */
class JavaPackageNameTest {

@Test
void acceptsJavaPackageNamesAndProjectsJavadocPaths() {
JavaPackageName packageName =
JavaPackageName.from("java.util.concurrent").orElseThrow();

assertEquals("java.util.concurrent", packageName.qualifiedName());
assertEquals("java/util/concurrent", packageName.javadocPath());
}

@Test
void rejectsAbsentPaddedAndInvalidPackageNames() {
List<String> invalidPackageNames = List.of(
"",
" ",
" java.util",
"java.util ",
"java..util",
"java.util.class-use",
"java.class",
"java.true",
"java.false",
"java.null");

assertTrue(JavaPackageName.from(null).isEmpty());
invalidPackageNames.forEach(candidatePackageName ->
assertTrue(JavaPackageName.from(candidatePackageName).isEmpty()));
}

@Test
void preventsDirectConstructionOfInvalidPackageNames() {
assertThrows(IllegalArgumentException.class, () -> new JavaPackageName("java.util.class-use"));
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: prefer canonical Javadoc type pages by WilliamAGH · Pull Request #113 · WilliamAGH/java-chat · GitHub
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
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
package com.williamcallahan.javachat.application.search;

import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand DownExpand Up@@ -94,25 +95,20 @@ public String typePageFileName() {
/**
* Determines whether a Javadoc URL path identifies this selector's declaring type.
*
* <p>Unqualified selectors match the type filename in any package. Qualified selectors require
* the exact package path immediately before that filename, preventing same-named JDK types in
* different packages from receiving the same citation priority.</p>
* <p>Qualified selectors use their query package and ignore the candidate package. Unqualified
* selectors require a canonical Java package derived from the candidate source URL, which
* keeps package-relative API pages from being mistaken for canonical type pages.</p>
*
* @param javadocPath decoded Javadoc URL path
* @return true when the path names this selector's declaring type
* @param candidatePackageName package derived from the candidate source URL, or {@code null}
* when absent
* @return true when the path names this selector's declaring type in its expected package
*/
public boolean matchesJavadocPath(String javadocPath) {
public boolean matchesJavadocPath(String javadocPath, String candidatePackageName) {
Objects.requireNonNull(javadocPath, "javadocPath");
int filenameStartIndex = javadocPath.lastIndexOf('/') + 1;
String candidateFilename = javadocPath.substring(filenameStartIndex);
if (!typePageFileName().equals(candidateFilename)) {
return false;
}
if (packageName.isBlank()) {
return true;
}
String qualifiedPagePathSuffix = "/" + packageName.replace('.', '/') + "/" + typePageFileName();
return javadocPath.endsWith(qualifiedPagePathSuffix);
return expectedPackageName(candidatePackageName)
.map(expectedPackageName -> matchesPackageTypePath(javadocPath, expectedPackageName))
.orElse(false);
}

/**
Expand All@@ -124,6 +120,18 @@ public String sparseQueryTerms() {
return typePageName;
}

private Optional<JavaPackageName> expectedPackageName(String candidatePackageName) {
if (!packageName.isBlank()) {
return JavaPackageName.from(packageName);
}
return JavaPackageName.from(candidatePackageName);
}

private boolean matchesPackageTypePath(String javadocPath, JavaPackageName expectedPackageName) {
String expectedPagePath = "/" + expectedPackageName.javadocPath() + "/" + typePageFileName();
return javadocPath.endsWith(expectedPagePath);
}

private static ParsedQualifiedName parseQualifiedName(String query, int startIndex) {
List<String> segments = new ArrayList<>();
int currentIndex = startIndex;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
package com.williamcallahan.javachat.domain.javaapi;

import java.util.Optional;
import javax.lang.model.SourceVersion;

/**
* Represents an exact, syntactically valid Java 25 package name.
*
* <p>The value preserves its source spelling so URL projections and query selectors share one
* package-identity boundary without trimming or accepting documentation-only path segments.</p>
*
* @param qualifiedName dot-separated Java package name
*/
public record JavaPackageName(String qualifiedName) {

/**
* Enforces exact Java 25 package-name syntax for every constructed value.
*
* @throws IllegalArgumentException when the package name is null, blank, padded, or invalid
*/
public JavaPackageName {
if (!isValid(qualifiedName)) {
throw new IllegalArgumentException("qualifiedName must be an exact Java 25 package name");
}
}

/**
* Creates a package name only when the candidate satisfies the Java 25 name grammar exactly.
*
* @param candidateQualifiedName candidate dot-separated package name
* @return validated package name, or empty when the candidate is absent or invalid
*/
public static Optional<JavaPackageName> from(String candidateQualifiedName) {
return isValid(candidateQualifiedName)
? Optional.of(new JavaPackageName(candidateQualifiedName))
: Optional.empty();
}

/**
* Projects the package into the path syntax used by canonical Javadoc URLs.
*
* @return slash-separated package path without leading or trailing delimiters
*/
public String javadocPath() {
return qualifiedName.replace('.', '/');
}

private static boolean isValid(String candidateQualifiedName) {
return candidateQualifiedName != null
&& !candidateQualifiedName.isBlank()
&& candidateQualifiedName.equals(candidateQualifiedName.trim())
&& SourceVersion.isName(candidateQualifiedName, SourceVersion.RELEASE_25);
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.application.search.JavaApiMethodSelector;
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
Expand DownExpand Up@@ -86,7 +87,8 @@ private static boolean matchesTypePage(JavaApiMethodSelector selector, Document
if (documentPath == null || documentPath.isBlank()) {
return false;
}
return selector.matchesJavadocPath(documentPath);
String candidatePackageName = JavaPackageExtractor.extractJavaApiPackage(sourceUrl);
return selector.matchesJavadocPath(documentPath, candidatePackageName);
}

private static boolean hasMethodDeclarationEvidence(JavaApiMethodSelector selector, Document citationCandidate) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.ModelConfiguration;
import com.williamcallahan.javachat.model.Citation;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor.VersionFilterPatterns;
import java.util.ArrayList;
Expand DownExpand Up@@ -415,10 +416,8 @@ public CitationOutcome toCitations(List<Document> documents) {
Map<String, ?> sourceDocMetadata = sourceDocument.getMetadata();
String rawUrl = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.URL_FIELD);
String title = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.TITLE_FIELD);
String packageName = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.PACKAGE_FIELD);
String documentType = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.DOC_TYPE_FIELD);
String refinedCitationUrl =
refineCitationUrl(rawUrl, sourceDocument.getText(), packageName, documentType);
String refinedCitationUrl = refineCitationUrl(rawUrl, sourceDocument.getText(), documentType);
String citationIdentity = citationIdentityFor(rawUrl, refinedCitationUrl);
if (!citationIdentity.isBlank() && !retainedCitationIdentities.add(citationIdentity)) {
continue;
Expand DownExpand Up@@ -478,10 +477,11 @@ private static String citationIdentityFor(String rawUrl, String citationUrl) {
* Refines a raw document URL and gates Javadoc member anchors to {@code api-docs} metadata.
*
*/
private String refineCitationUrl(String rawUrl, String documentText, String packageName, String documentType) {
private String refineCitationUrl(String rawUrl, String documentText, String documentType) {
String normalizedUrl = DocsSourceRegistry.normalizeDocUrl(rawUrl);
String citationUrl = normalizedUrl;
if (DOCUMENT_TYPE_API_DOCS.equals(documentType)) {
String packageName = JavaPackageExtractor.extractJavaApiPackage(normalizedUrl);
String nestedTypeRefinedUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineNestedTypeUrl(
citationUrl, documentText);
citationUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineMemberAnchorUrl(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource;
import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import com.williamcallahan.javachat.support.AsciiTextNormalizer;
import java.net.URI;
import java.net.URISyntaxException;
Expand DownExpand Up@@ -34,6 +35,25 @@ public static boolean isJavaApiUrl(String url) {
return findJavaApiSourceUrl(url).isPresent();
}

/**
* Derives the Java package encoded by a manifest-governed Java API source URL.
*
* <p>URL consumers use this projection instead of persisted package metadata so package
* identity follows the canonical source path across ingestion generations.</p>
*
* @param url source URL
* @return package encoded by the canonical Java API path, or an empty string when the URL is
* not a canonical Java API source or its path does not identify a package
*/
public static String extractJavaApiPackage(String url) {
Objects.requireNonNull(url, "url");
return findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.flatMap(JavaPackageName::from)
.map(JavaPackageName::qualifiedName)
.orElse("");
}

/**
* Attempts to derive a package name from the URL and extracted page text.
*
Expand All@@ -45,9 +65,7 @@ public static String extractPackage(String url, String bodyText) {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(bodyText, "bodyText");

String pathDerivedPackageName = findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.orElse("");
String pathDerivedPackageName = extractJavaApiPackage(url);
if (!pathDerivedPackageName.isBlank()) {
return pathDerivedPackageName;
}
Expand All@@ -58,7 +76,10 @@ public static String extractPackage(String url, String bodyText) {
String snippet = bodyText.substring(packageIndex, end);
for (String token : snippet.split("\\s+")) {
if (AsciiTextNormalizer.toLowerAscii(token).startsWith("java.")) {
return token.replaceAll("[,.;]$", "");
String candidatePackageName = token.replaceAll("[,.;]$", "");
return JavaPackageName.from(candidatePackageName)
.map(JavaPackageName::qualifiedName)
.orElse("");
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ public static String refineNestedTypeUrl(String url, String text) {
*
* @param url Javadoc page URL ending with .html
* @param text Extracted chunk text from the same page
* @param packageName Package name of the type (from metadata), can be empty
* @param packageName Java package derived from the canonical Javadoc source URL, can be empty
* @return URL with a fragment to the member if a confident match is found; original URL otherwise
*/
public static String refineMemberAnchorUrl(String url, String text, String packageName) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,7 @@ void normalizesWhitespaceWhenConstructedDirectly() {
assertEquals("of", selector.methodName());
assertEquals("List.html", selector.typePageFileName());
assertEquals("List", selector.sparseQueryTerms());
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html"));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
Expand All@@ -76,6 +76,25 @@ void rejectsFilenameShapedPseudoMethods() {
void requiresCaseSensitiveJavadocTypePageNames() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "List", "of");

assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html", null));
}

@Test
void requiresCanonicalCandidatePackageForUnqualifiedSelectors() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("", "List", "of");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", "java.util"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/class-use/List.html", "java.util.class-use"));
assertFalse(selector.matchesJavadocPath("/List.html", ""));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
void matchesQualifiedSelectorsByPathRegardlessOfCandidateMetadata() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "Date", "toString");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", null));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", "java.sql"));
assertFalse(selector.matchesJavadocPath("/java.sql/java/sql/Date.html", "java.util"));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
package com.williamcallahan.javachat.domain.javaapi;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;

/** Verifies exact Java 25 package validation and Javadoc path projection. */
class JavaPackageNameTest {

@Test
void acceptsJavaPackageNamesAndProjectsJavadocPaths() {
JavaPackageName packageName =
JavaPackageName.from("java.util.concurrent").orElseThrow();

assertEquals("java.util.concurrent", packageName.qualifiedName());
assertEquals("java/util/concurrent", packageName.javadocPath());
}

@Test
void rejectsAbsentPaddedAndInvalidPackageNames() {
List<String> invalidPackageNames = List.of(
"",
" ",
" java.util",
"java.util ",
"java..util",
"java.util.class-use",
"java.class",
"java.true",
"java.false",
"java.null");

assertTrue(JavaPackageName.from(null).isEmpty());
invalidPackageNames.forEach(candidatePackageName ->
assertTrue(JavaPackageName.from(candidatePackageName).isEmpty()));
}

@Test
void preventsDirectConstructionOfInvalidPackageNames() {
assertThrows(IllegalArgumentException.class, () -> new JavaPackageName("java.util.class-use"));
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: prefer canonical Javadoc type pages by WilliamAGH · Pull Request #113 · WilliamAGH/java-chat · GitHub
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
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
package com.williamcallahan.javachat.application.search;

import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand DownExpand Up@@ -94,25 +95,20 @@ public String typePageFileName() {
/**
* Determines whether a Javadoc URL path identifies this selector's declaring type.
*
* <p>Unqualified selectors match the type filename in any package. Qualified selectors require
* the exact package path immediately before that filename, preventing same-named JDK types in
* different packages from receiving the same citation priority.</p>
* <p>Qualified selectors use their query package and ignore the candidate package. Unqualified
* selectors require a canonical Java package derived from the candidate source URL, which
* keeps package-relative API pages from being mistaken for canonical type pages.</p>
*
* @param javadocPath decoded Javadoc URL path
* @return true when the path names this selector's declaring type
* @param candidatePackageName package derived from the candidate source URL, or {@code null}
* when absent
* @return true when the path names this selector's declaring type in its expected package
*/
public boolean matchesJavadocPath(String javadocPath) {
public boolean matchesJavadocPath(String javadocPath, String candidatePackageName) {
Objects.requireNonNull(javadocPath, "javadocPath");
int filenameStartIndex = javadocPath.lastIndexOf('/') + 1;
String candidateFilename = javadocPath.substring(filenameStartIndex);
if (!typePageFileName().equals(candidateFilename)) {
return false;
}
if (packageName.isBlank()) {
return true;
}
String qualifiedPagePathSuffix = "/" + packageName.replace('.', '/') + "/" + typePageFileName();
return javadocPath.endsWith(qualifiedPagePathSuffix);
return expectedPackageName(candidatePackageName)
.map(expectedPackageName -> matchesPackageTypePath(javadocPath, expectedPackageName))
.orElse(false);
}

/**
Expand All@@ -124,6 +120,18 @@ public String sparseQueryTerms() {
return typePageName;
}

private Optional<JavaPackageName> expectedPackageName(String candidatePackageName) {
if (!packageName.isBlank()) {
return JavaPackageName.from(packageName);
}
return JavaPackageName.from(candidatePackageName);
}

private boolean matchesPackageTypePath(String javadocPath, JavaPackageName expectedPackageName) {
String expectedPagePath = "/" + expectedPackageName.javadocPath() + "/" + typePageFileName();
return javadocPath.endsWith(expectedPagePath);
}

private static ParsedQualifiedName parseQualifiedName(String query, int startIndex) {
List<String> segments = new ArrayList<>();
int currentIndex = startIndex;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
package com.williamcallahan.javachat.domain.javaapi;

import java.util.Optional;
import javax.lang.model.SourceVersion;

/**
* Represents an exact, syntactically valid Java 25 package name.
*
* <p>The value preserves its source spelling so URL projections and query selectors share one
* package-identity boundary without trimming or accepting documentation-only path segments.</p>
*
* @param qualifiedName dot-separated Java package name
*/
public record JavaPackageName(String qualifiedName) {

/**
* Enforces exact Java 25 package-name syntax for every constructed value.
*
* @throws IllegalArgumentException when the package name is null, blank, padded, or invalid
*/
public JavaPackageName {
if (!isValid(qualifiedName)) {
throw new IllegalArgumentException("qualifiedName must be an exact Java 25 package name");
}
}

/**
* Creates a package name only when the candidate satisfies the Java 25 name grammar exactly.
*
* @param candidateQualifiedName candidate dot-separated package name
* @return validated package name, or empty when the candidate is absent or invalid
*/
public static Optional<JavaPackageName> from(String candidateQualifiedName) {
return isValid(candidateQualifiedName)
? Optional.of(new JavaPackageName(candidateQualifiedName))
: Optional.empty();
}

/**
* Projects the package into the path syntax used by canonical Javadoc URLs.
*
* @return slash-separated package path without leading or trailing delimiters
*/
public String javadocPath() {
return qualifiedName.replace('.', '/');
}

private static boolean isValid(String candidateQualifiedName) {
return candidateQualifiedName != null
&& !candidateQualifiedName.isBlank()
&& candidateQualifiedName.equals(candidateQualifiedName.trim())
&& SourceVersion.isName(candidateQualifiedName, SourceVersion.RELEASE_25);
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.application.search.JavaApiMethodSelector;
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
Expand DownExpand Up@@ -86,7 +87,8 @@ private static boolean matchesTypePage(JavaApiMethodSelector selector, Document
if (documentPath == null || documentPath.isBlank()) {
return false;
}
return selector.matchesJavadocPath(documentPath);
String candidatePackageName = JavaPackageExtractor.extractJavaApiPackage(sourceUrl);
return selector.matchesJavadocPath(documentPath, candidatePackageName);
}

private static boolean hasMethodDeclarationEvidence(JavaApiMethodSelector selector, Document citationCandidate) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.ModelConfiguration;
import com.williamcallahan.javachat.model.Citation;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor.VersionFilterPatterns;
import java.util.ArrayList;
Expand DownExpand Up@@ -415,10 +416,8 @@ public CitationOutcome toCitations(List<Document> documents) {
Map<String, ?> sourceDocMetadata = sourceDocument.getMetadata();
String rawUrl = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.URL_FIELD);
String title = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.TITLE_FIELD);
String packageName = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.PACKAGE_FIELD);
String documentType = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.DOC_TYPE_FIELD);
String refinedCitationUrl =
refineCitationUrl(rawUrl, sourceDocument.getText(), packageName, documentType);
String refinedCitationUrl = refineCitationUrl(rawUrl, sourceDocument.getText(), documentType);
String citationIdentity = citationIdentityFor(rawUrl, refinedCitationUrl);
if (!citationIdentity.isBlank() && !retainedCitationIdentities.add(citationIdentity)) {
continue;
Expand DownExpand Up@@ -478,10 +477,11 @@ private static String citationIdentityFor(String rawUrl, String citationUrl) {
* Refines a raw document URL and gates Javadoc member anchors to {@code api-docs} metadata.
*
*/
private String refineCitationUrl(String rawUrl, String documentText, String packageName, String documentType) {
private String refineCitationUrl(String rawUrl, String documentText, String documentType) {
String normalizedUrl = DocsSourceRegistry.normalizeDocUrl(rawUrl);
String citationUrl = normalizedUrl;
if (DOCUMENT_TYPE_API_DOCS.equals(documentType)) {
String packageName = JavaPackageExtractor.extractJavaApiPackage(normalizedUrl);
String nestedTypeRefinedUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineNestedTypeUrl(
citationUrl, documentText);
citationUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineMemberAnchorUrl(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource;
import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import com.williamcallahan.javachat.support.AsciiTextNormalizer;
import java.net.URI;
import java.net.URISyntaxException;
Expand DownExpand Up@@ -34,6 +35,25 @@ public static boolean isJavaApiUrl(String url) {
return findJavaApiSourceUrl(url).isPresent();
}

/**
* Derives the Java package encoded by a manifest-governed Java API source URL.
*
* <p>URL consumers use this projection instead of persisted package metadata so package
* identity follows the canonical source path across ingestion generations.</p>
*
* @param url source URL
* @return package encoded by the canonical Java API path, or an empty string when the URL is
* not a canonical Java API source or its path does not identify a package
*/
public static String extractJavaApiPackage(String url) {
Objects.requireNonNull(url, "url");
return findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.flatMap(JavaPackageName::from)
.map(JavaPackageName::qualifiedName)
.orElse("");
}

/**
* Attempts to derive a package name from the URL and extracted page text.
*
Expand All@@ -45,9 +65,7 @@ public static String extractPackage(String url, String bodyText) {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(bodyText, "bodyText");

String pathDerivedPackageName = findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.orElse("");
String pathDerivedPackageName = extractJavaApiPackage(url);
if (!pathDerivedPackageName.isBlank()) {
return pathDerivedPackageName;
}
Expand All@@ -58,7 +76,10 @@ public static String extractPackage(String url, String bodyText) {
String snippet = bodyText.substring(packageIndex, end);
for (String token : snippet.split("\\s+")) {
if (AsciiTextNormalizer.toLowerAscii(token).startsWith("java.")) {
return token.replaceAll("[,.;]$", "");
String candidatePackageName = token.replaceAll("[,.;]$", "");
return JavaPackageName.from(candidatePackageName)
.map(JavaPackageName::qualifiedName)
.orElse("");
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ public static String refineNestedTypeUrl(String url, String text) {
*
* @param url Javadoc page URL ending with .html
* @param text Extracted chunk text from the same page
* @param packageName Package name of the type (from metadata), can be empty
* @param packageName Java package derived from the canonical Javadoc source URL, can be empty
* @return URL with a fragment to the member if a confident match is found; original URL otherwise
*/
public static String refineMemberAnchorUrl(String url, String text, String packageName) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,7 @@ void normalizesWhitespaceWhenConstructedDirectly() {
assertEquals("of", selector.methodName());
assertEquals("List.html", selector.typePageFileName());
assertEquals("List", selector.sparseQueryTerms());
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html"));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
Expand All@@ -76,6 +76,25 @@ void rejectsFilenameShapedPseudoMethods() {
void requiresCaseSensitiveJavadocTypePageNames() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "List", "of");

assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html", null));
}

@Test
void requiresCanonicalCandidatePackageForUnqualifiedSelectors() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("", "List", "of");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", "java.util"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/class-use/List.html", "java.util.class-use"));
assertFalse(selector.matchesJavadocPath("/List.html", ""));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
void matchesQualifiedSelectorsByPathRegardlessOfCandidateMetadata() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "Date", "toString");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", null));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", "java.sql"));
assertFalse(selector.matchesJavadocPath("/java.sql/java/sql/Date.html", "java.util"));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
package com.williamcallahan.javachat.domain.javaapi;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;

/** Verifies exact Java 25 package validation and Javadoc path projection. */
class JavaPackageNameTest {

@Test
void acceptsJavaPackageNamesAndProjectsJavadocPaths() {
JavaPackageName packageName =
JavaPackageName.from("java.util.concurrent").orElseThrow();

assertEquals("java.util.concurrent", packageName.qualifiedName());
assertEquals("java/util/concurrent", packageName.javadocPath());
}

@Test
void rejectsAbsentPaddedAndInvalidPackageNames() {
List<String> invalidPackageNames = List.of(
"",
" ",
" java.util",
"java.util ",
"java..util",
"java.util.class-use",
"java.class",
"java.true",
"java.false",
"java.null");

assertTrue(JavaPackageName.from(null).isEmpty());
invalidPackageNames.forEach(candidatePackageName ->
assertTrue(JavaPackageName.from(candidatePackageName).isEmpty()));
}

@Test
void preventsDirectConstructionOfInvalidPackageNames() {
assertThrows(IllegalArgumentException.class, () -> new JavaPackageName("java.util.class-use"));
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix: prefer canonical Javadoc type pages by WilliamAGH · Pull Request #113 · WilliamAGH/java-chat · GitHub
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
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
package com.williamcallahan.javachat.application.search;

import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand DownExpand Up@@ -94,25 +95,20 @@ public String typePageFileName() {
/**
* Determines whether a Javadoc URL path identifies this selector's declaring type.
*
* <p>Unqualified selectors match the type filename in any package. Qualified selectors require
* the exact package path immediately before that filename, preventing same-named JDK types in
* different packages from receiving the same citation priority.</p>
* <p>Qualified selectors use their query package and ignore the candidate package. Unqualified
* selectors require a canonical Java package derived from the candidate source URL, which
* keeps package-relative API pages from being mistaken for canonical type pages.</p>
*
* @param javadocPath decoded Javadoc URL path
* @return true when the path names this selector's declaring type
* @param candidatePackageName package derived from the candidate source URL, or {@code null}
* when absent
* @return true when the path names this selector's declaring type in its expected package
*/
public boolean matchesJavadocPath(String javadocPath) {
public boolean matchesJavadocPath(String javadocPath, String candidatePackageName) {
Objects.requireNonNull(javadocPath, "javadocPath");
int filenameStartIndex = javadocPath.lastIndexOf('/') + 1;
String candidateFilename = javadocPath.substring(filenameStartIndex);
if (!typePageFileName().equals(candidateFilename)) {
return false;
}
if (packageName.isBlank()) {
return true;
}
String qualifiedPagePathSuffix = "/" + packageName.replace('.', '/') + "/" + typePageFileName();
return javadocPath.endsWith(qualifiedPagePathSuffix);
return expectedPackageName(candidatePackageName)
.map(expectedPackageName -> matchesPackageTypePath(javadocPath, expectedPackageName))
.orElse(false);
}

/**
Expand All@@ -124,6 +120,18 @@ public String sparseQueryTerms() {
return typePageName;
}

private Optional<JavaPackageName> expectedPackageName(String candidatePackageName) {
if (!packageName.isBlank()) {
return JavaPackageName.from(packageName);
}
return JavaPackageName.from(candidatePackageName);
}

private boolean matchesPackageTypePath(String javadocPath, JavaPackageName expectedPackageName) {
String expectedPagePath = "/" + expectedPackageName.javadocPath() + "/" + typePageFileName();
return javadocPath.endsWith(expectedPagePath);
}

private static ParsedQualifiedName parseQualifiedName(String query, int startIndex) {
List<String> segments = new ArrayList<>();
int currentIndex = startIndex;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
package com.williamcallahan.javachat.domain.javaapi;

import java.util.Optional;
import javax.lang.model.SourceVersion;

/**
* Represents an exact, syntactically valid Java 25 package name.
*
* <p>The value preserves its source spelling so URL projections and query selectors share one
* package-identity boundary without trimming or accepting documentation-only path segments.</p>
*
* @param qualifiedName dot-separated Java package name
*/
public record JavaPackageName(String qualifiedName) {

/**
* Enforces exact Java 25 package-name syntax for every constructed value.
*
* @throws IllegalArgumentException when the package name is null, blank, padded, or invalid
*/
public JavaPackageName {
if (!isValid(qualifiedName)) {
throw new IllegalArgumentException("qualifiedName must be an exact Java 25 package name");
}
}

/**
* Creates a package name only when the candidate satisfies the Java 25 name grammar exactly.
*
* @param candidateQualifiedName candidate dot-separated package name
* @return validated package name, or empty when the candidate is absent or invalid
*/
public static Optional<JavaPackageName> from(String candidateQualifiedName) {
return isValid(candidateQualifiedName)
? Optional.of(new JavaPackageName(candidateQualifiedName))
: Optional.empty();
}

/**
* Projects the package into the path syntax used by canonical Javadoc URLs.
*
* @return slash-separated package path without leading or trailing delimiters
*/
public String javadocPath() {
return qualifiedName.replace('.', '/');
}

private static boolean isValid(String candidateQualifiedName) {
return candidateQualifiedName != null
&& !candidateQualifiedName.isBlank()
&& candidateQualifiedName.equals(candidateQualifiedName.trim())
&& SourceVersion.isName(candidateQualifiedName, SourceVersion.RELEASE_25);
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.application.search.JavaApiMethodSelector;
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
Expand DownExpand Up@@ -86,7 +87,8 @@ private static boolean matchesTypePage(JavaApiMethodSelector selector, Document
if (documentPath == null || documentPath.isBlank()) {
return false;
}
return selector.matchesJavadocPath(documentPath);
String candidatePackageName = JavaPackageExtractor.extractJavaApiPackage(sourceUrl);
return selector.matchesJavadocPath(documentPath, candidatePackageName);
}

private static boolean hasMethodDeclarationEvidence(JavaApiMethodSelector selector, Document citationCandidate) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@
import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.ModelConfiguration;
import com.williamcallahan.javachat.model.Citation;
import com.williamcallahan.javachat.service.ingestion.JavaPackageExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor;
import com.williamcallahan.javachat.util.QueryVersionExtractor.VersionFilterPatterns;
import java.util.ArrayList;
Expand DownExpand Up@@ -415,10 +416,8 @@ public CitationOutcome toCitations(List<Document> documents) {
Map<String, ?> sourceDocMetadata = sourceDocument.getMetadata();
String rawUrl = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.URL_FIELD);
String title = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.TITLE_FIELD);
String packageName = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.PACKAGE_FIELD);
String documentType = stringMetadataValue(sourceDocMetadata, QdrantPayloadFieldSchema.DOC_TYPE_FIELD);
String refinedCitationUrl =
refineCitationUrl(rawUrl, sourceDocument.getText(), packageName, documentType);
String refinedCitationUrl = refineCitationUrl(rawUrl, sourceDocument.getText(), documentType);
String citationIdentity = citationIdentityFor(rawUrl, refinedCitationUrl);
if (!citationIdentity.isBlank() && !retainedCitationIdentities.add(citationIdentity)) {
continue;
Expand DownExpand Up@@ -478,10 +477,11 @@ private static String citationIdentityFor(String rawUrl, String citationUrl) {
* Refines a raw document URL and gates Javadoc member anchors to {@code api-docs} metadata.
*
*/
private String refineCitationUrl(String rawUrl, String documentText, String packageName, String documentType) {
private String refineCitationUrl(String rawUrl, String documentText, String documentType) {
String normalizedUrl = DocsSourceRegistry.normalizeDocUrl(rawUrl);
String citationUrl = normalizedUrl;
if (DOCUMENT_TYPE_API_DOCS.equals(documentType)) {
String packageName = JavaPackageExtractor.extractJavaApiPackage(normalizedUrl);
String nestedTypeRefinedUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineNestedTypeUrl(
citationUrl, documentText);
citationUrl = com.williamcallahan.javachat.util.JavadocLinkResolver.refineMemberAnchorUrl(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import com.williamcallahan.javachat.config.DocsSourceRegistry;
import com.williamcallahan.javachat.config.DocsSourceRegistry.JavaApiDocumentationSource;
import com.williamcallahan.javachat.domain.javaapi.JavaPackageName;
import com.williamcallahan.javachat.support.AsciiTextNormalizer;
import java.net.URI;
import java.net.URISyntaxException;
Expand DownExpand Up@@ -34,6 +35,25 @@ public static boolean isJavaApiUrl(String url) {
return findJavaApiSourceUrl(url).isPresent();
}

/**
* Derives the Java package encoded by a manifest-governed Java API source URL.
*
* <p>URL consumers use this projection instead of persisted package metadata so package
* identity follows the canonical source path across ingestion generations.</p>
*
* @param url source URL
* @return package encoded by the canonical Java API path, or an empty string when the URL is
* not a canonical Java API source or its path does not identify a package
*/
public static String extractJavaApiPackage(String url) {
Objects.requireNonNull(url, "url");
return findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.flatMap(JavaPackageName::from)
.map(JavaPackageName::qualifiedName)
.orElse("");
}

/**
* Attempts to derive a package name from the URL and extracted page text.
*
Expand All@@ -45,9 +65,7 @@ public static String extractPackage(String url, String bodyText) {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(bodyText, "bodyText");

String pathDerivedPackageName = findJavaApiSourceUrl(url)
.map(JavaPackageExtractor::extractPackageFromJavaApiPath)
.orElse("");
String pathDerivedPackageName = extractJavaApiPackage(url);
if (!pathDerivedPackageName.isBlank()) {
return pathDerivedPackageName;
}
Expand All@@ -58,7 +76,10 @@ public static String extractPackage(String url, String bodyText) {
String snippet = bodyText.substring(packageIndex, end);
for (String token : snippet.split("\\s+")) {
if (AsciiTextNormalizer.toLowerAscii(token).startsWith("java.")) {
return token.replaceAll("[,.;]$", "");
String candidatePackageName = token.replaceAll("[,.;]$", "");
return JavaPackageName.from(candidatePackageName)
.map(JavaPackageName::qualifiedName)
.orElse("");
}
}
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ public static String refineNestedTypeUrl(String url, String text) {
*
* @param url Javadoc page URL ending with .html
* @param text Extracted chunk text from the same page
* @param packageName Package name of the type (from metadata), can be empty
* @param packageName Java package derived from the canonical Javadoc source URL, can be empty
* @return URL with a fragment to the member if a confident match is found; original URL otherwise
*/
public static String refineMemberAnchorUrl(String url, String text, String packageName) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,7 +57,7 @@ void normalizesWhitespaceWhenConstructedDirectly() {
assertEquals("of", selector.methodName());
assertEquals("List.html", selector.typePageFileName());
assertEquals("List", selector.sparseQueryTerms());
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html"));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
Expand All@@ -76,6 +76,25 @@ void rejectsFilenameShapedPseudoMethods() {
void requiresCaseSensitiveJavadocTypePageNames() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "List", "of");

assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/list.html", null));
}

@Test
void requiresCanonicalCandidatePackageForUnqualifiedSelectors() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("", "List", "of");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/List.html", "java.util"));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/class-use/List.html", "java.util.class-use"));
assertFalse(selector.matchesJavadocPath("/List.html", ""));
assertFalse(selector.matchesJavadocPath("/java.base/java/util/List.html", null));
}

@Test
void matchesQualifiedSelectorsByPathRegardlessOfCandidateMetadata() {
JavaApiMethodSelector selector = new JavaApiMethodSelector("java.util", "Date", "toString");

assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", null));
assertTrue(selector.matchesJavadocPath("/java.base/java/util/Date.html", "java.sql"));
assertFalse(selector.matchesJavadocPath("/java.sql/java/sql/Date.html", "java.util"));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
package com.williamcallahan.javachat.domain.javaapi;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;

/** Verifies exact Java 25 package validation and Javadoc path projection. */
class JavaPackageNameTest {

@Test
void acceptsJavaPackageNamesAndProjectsJavadocPaths() {
JavaPackageName packageName =
JavaPackageName.from("java.util.concurrent").orElseThrow();

assertEquals("java.util.concurrent", packageName.qualifiedName());
assertEquals("java/util/concurrent", packageName.javadocPath());
}

@Test
void rejectsAbsentPaddedAndInvalidPackageNames() {
List<String> invalidPackageNames = List.of(
"",
" ",
" java.util",
"java.util ",
"java..util",
"java.util.class-use",
"java.class",
"java.true",
"java.false",
"java.null");

assertTrue(JavaPackageName.from(null).isEmpty());
invalidPackageNames.forEach(candidatePackageName ->
assertTrue(JavaPackageName.from(candidatePackageName).isEmpty()));
}

@Test
void preventsDirectConstructionOfInvalidPackageNames() {
assertThrows(IllegalArgumentException.class, () -> new JavaPackageName("java.util.class-use"));
}
}
Loading