diff --git a/.changeset/dollar-anchor-case.md b/.changeset/dollar-anchor-case.md new file mode 100644 index 0000000..3485940 --- /dev/null +++ b/.changeset/dollar-anchor-case.md @@ -0,0 +1,5 @@ +--- +'intersphinx': patch +--- + +Expand the `$` uri anchor shorthand with the object name verbatim (case-preserved), matching Sphinx semantics. Previously the expansion was lowercased and whitespace-replaced, which broke links to any case-distinct targets (e.g. `re.Match` vs `re.match` in the CPython inventory, and capitalized glossary terms like `#term-Match`). diff --git a/src/intersphinx.ts b/src/intersphinx.ts index fa5f1ca..95a3bdd 100644 --- a/src/intersphinx.ts +++ b/src/intersphinx.ts @@ -152,9 +152,11 @@ export class Inventory { resolvedLocation = resolvedLocation.slice(1); } if (resolvedLocation.endsWith('$')) { - // Maybe move this to the parse function only? - resolvedLocation = - resolvedLocation.slice(0, -1) + entry.name.toLowerCase().replace(/\s+/g, '-'); + // Sphinx semantics: `$` is replaced by the object name *verbatim*. + // Case must be preserved: e.g. `re.match` (function) and `re.Match` + // (class) are distinct objects with distinct anchors. + // https://github.com/sphinx-doc/sphinx/blob/v8.2.3/sphinx/util/inventory.py#L154 + resolvedLocation = resolvedLocation.slice(0, -1) + entry.name; } const resolvedDisplay = !entry.display || entry.display.trim() === '-' ? undefined : entry.display.trim(); diff --git a/src/inventory.spec.ts b/src/inventory.spec.ts index 3421e3a..84786df 100644 --- a/src/inventory.spec.ts +++ b/src/inventory.spec.ts @@ -22,4 +22,36 @@ describe('Test Inventory', () => { expect(entry?.location?.includes('abc.html')).toBe(true); expect(entry?.type).toBe('std:doc'); }); + test('`$` anchor shorthand expands to the name verbatim (Sphinx semantics)', () => { + const inv = new Inventory({ id: 'test', path: 'https://example.org' }); + // Sphinx only writes `$` when the anchor ends with the name byte-for-byte, + // so the expansion must preserve case exactly: + // re.match (function) and re.Match (class) are distinct targets. + inv.setEntry({ type: 'py:function', name: 're.match', location: 'library/re.html#$' }); + inv.setEntry({ type: 'py:class', name: 're.Match', location: 'library/re.html#$' }); + // A capitalized glossary term: Sphinx writes `Match std:term -1 index.html#term-$ -` + // and its real HTML anchor is `id="term-Match"` — also case-preserved. + inv.setEntry({ type: 'std:term', name: 'Match', location: 'index.html#term-$' }); + expect(inv.getEntry({ name: 're.match' })?.location).toBe( + 'https://example.org/library/re.html#re.match', + ); + expect(inv.getEntry({ name: 're.Match' })?.location).toBe( + 'https://example.org/library/re.html#re.Match', + ); + expect(inv.getEntry({ type: 'std:term', name: 'Match' })?.location).toBe( + 'https://example.org/index.html#term-Match', + ); + }); + test('Python inventory - case sensitive names', async () => { + // Python 3.11 includes both `class Match` and `def match()` as targets + const inv = new Inventory({ path: 'https://docs.python.org/3.11' }); + await inv.load(); + expect(inv._loaded).toBe(true); + const entryClass = inv.getEntry({ name: 're.Match' }); + expect(entryClass?.location?.endsWith('re.Match')).toBe(true); + expect(entryClass?.type).toBe('py:class'); + const entryFunc = inv.getEntry({ name: 're.match' }); + expect(entryFunc?.location?.endsWith('re.match')).toBe(true); + expect(entryFunc?.type).toBe('py:function'); + }); });