diff --git a/CHANGELOG.md b/CHANGELOG.md index d31ca6ad..f611cd11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ See [extension/CHANGELOG.md](extension/CHANGELOG.md). - Editor moved from CodeMirror 5 to CodeMirror 6 with Lezer grammars for 6502, Z80, 6809, Verilog, BASIC, batari BASIC, FastBasic, Inform 6 and Wiz; new Cobalt and MBO themes and better indent handling. - Rust syntax highlighting for `.rs` files, via `@codemirror/lang-rust`; the urust toolchain now claims the `rust` editor style instead of falling back to C. +- A `mod foo;` line shows one include-link arrow instead of two (it had linked to both `foo.rs` and `foo/mod.rs`); the arrow now points at whichever file exists. - Source search across the project and toolchain headers (F1 for help), with arrow-key navigation, plus symbol search without source. - Shortcut bar and keyboard shortcut help, F8 pause/resume, Cmd+Shift+G go to offset. - Settings menu with developer options (highlight whitespace, highlight executed lines, line numbers off by default). diff --git a/src/ide/views/editors.ts b/src/ide/views/editors.ts index cb457ccd..60cf9c51 100644 --- a/src/ide/views/editors.ts +++ b/src/ide/views/editors.ts @@ -364,6 +364,9 @@ export class SourceEditor implements ProjectView { // filesystem via the read-only viewer openHeaderFile(filename); }, + // one directive can offer several candidates (`mod foo;` -> foo.rs + // or foo/mod.rs); link to whichever file exists + (filename: string) => resolveIncludeFile(filename) != null, ), textTransformFilterCompartment.of([]), diff --git a/src/ide/views/includedecorations.ts b/src/ide/views/includedecorations.ts index ff068cc5..d66db155 100644 --- a/src/ide/views/includedecorations.ts +++ b/src/ide/views/includedecorations.ts @@ -1,3 +1,4 @@ +import { EditorState, Text } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType } from "@codemirror/view"; import { ToolIncludePattern } from "../../common/toolmeta"; @@ -26,12 +27,31 @@ class IncludeLinkWidget extends WidgetType { ignoreEvent() { return false; } } -function buildIncludeLinkDecorations(view: EditorView, patterns: (RegExp | ToolIncludePattern)[], handleClick: (filename: string, system: boolean) => void): DecorationSet { - const widgets: any[] = []; - const doc = view.state.doc; - const seen = new Set(); // dedupe (line number, filename) - for (let { from, to } of view.visibleRanges) { - const text = view.state.sliceDoc(from, to); +/** One include badge: the document position to draw it at (end of its line). */ +export interface IncludeLink { + pos: number; + filename: string; + system: boolean; +} + +/** + * Find the include badges for the given document ranges. A single directive + * can match several patterns -- urust's `mod foo;` resolves to both foo.rs + * and foo/mod.rs -- and gets one badge, not one per pattern, so `mod` lines + * no longer show two arrows. When `resolveFile` is given, the candidate it + * names is preferred, so the badge links to the file that exists. Pure: no + * editor or DOM needed. + */ +export function findIncludeLinks( + doc: Text, + ranges: readonly { from: number, to: number }[], + patterns: (RegExp | ToolIncludePattern)[], + resolveFile?: (filename: string) => boolean, +): IncludeLink[] { + // group matches by the directive that produced them: line number + match offset + const groups = new Map(); + for (let { from, to } of ranges) { + const text = doc.sliceString(from, to); for (let pat of patterns) { const p: ToolIncludePattern = pat instanceof RegExp ? { re: pat } : pat; p.re.lastIndex = 0; // patterns are shared and global, so rewind first @@ -42,29 +62,53 @@ function buildIncludeLinkDecorations(view: EditorView, patterns: (RegExp | ToolI // apply any implicit extension (e.g. wiz `import "nes"` -> "nes.wiz"), // same as matchDependencyPatterns() does for build deps const filename = p.suffix ? matched + p.suffix : matched; - // place widget at end of the line containing the match const matchEnd = Math.min(from + m.index + m[0].length, doc.length); const line = doc.lineAt(matchEnd); - const key = line.number + ':' + filename; - if (seen.has(key)) continue; - seen.add(key); - widgets.push( - Decoration.widget({ - widget: new IncludeLinkWidget(filename, !!p.system, handleClick), - side: 1, - }).range(line.to) - ); + const key = line.number + ':' + (from + m.index); + let group = groups.get(key); + if (!group) { + group = { line: line.number, candidates: [] }; + groups.set(key, group); + } + group.candidates.push({ filename, system: !!p.system }); } } } + const links: IncludeLink[] = []; + for (let group of groups.values()) { + let chosen = group.candidates[0]; + if (resolveFile) { + chosen = group.candidates.find(c => resolveFile(c.filename)) || chosen; + } + links.push({ pos: doc.line(group.line).to, filename: chosen.filename, system: chosen.system }); + } + return links; +} + +function buildIncludeLinkDecorations( + state: EditorState, + ranges: readonly { from: number, to: number }[], + patterns: (RegExp | ToolIncludePattern)[], + handleClick: (filename: string, system: boolean) => void, + resolveFile?: (filename: string) => boolean, +): DecorationSet { + const widgets = findIncludeLinks(state.doc, ranges, patterns, resolveFile).map(link => + Decoration.widget({ + widget: new IncludeLinkWidget(link.filename, link.system, handleClick), + side: 1, + }).range(link.pos)); return Decoration.set(widgets, true); } -export function createIncludeLinkPlugin(getPatterns: () => (RegExp | ToolIncludePattern)[], onClick: (filename: string, system: boolean) => void) { +export function createIncludeLinkPlugin( + getPatterns: () => (RegExp | ToolIncludePattern)[], + onClick: (filename: string, system: boolean) => void, + resolveFile?: (filename: string) => boolean, +) { function build(view: EditorView): DecorationSet { const patterns = getPatterns(); if (!patterns || !patterns.length) return Decoration.none; - return buildIncludeLinkDecorations(view, patterns, onClick); + return buildIncludeLinkDecorations(view.state, view.visibleRanges, patterns, onClick, resolveFile); } return ViewPlugin.fromClass(class { decorations: DecorationSet; diff --git a/test/unit/testrustlang.ts b/test/unit/testrustlang.ts index 38be4bea..10fe6ed2 100644 --- a/test/unit/testrustlang.ts +++ b/test/unit/testrustlang.ts @@ -3,7 +3,8 @@ import { describe, it } from "mocha"; import { rust } from "@codemirror/lang-rust"; import { syntaxTree } from "@codemirror/language"; import { EditorState } from "@codemirror/state"; -import { TOOL_META, getToolMetaForFilename } from "../../src/common/toolmeta"; +import { TOOL_META, URUST_INCLUDE_PATTERNS, getToolMetaForFilename } from "../../src/common/toolmeta"; +import { findIncludeLinks } from "../../src/ide/views/includedecorations"; import { extractSymbols } from "../../src/common/symbols"; describe('Rust editor support', function () { @@ -22,6 +23,19 @@ describe('Rust editor support', function () { } }); + it('shows one include badge per mod line, preferring the file that exists', function () { + const doc = EditorState.create({ doc: 'mod audio;\nmod bcd;\n' }).doc; + const ranges = [{ from: 0, to: doc.length }]; + // both foo.rs and foo/mod.rs patterns match each mod line; keep one badge + assert.deepStrictEqual( + findIncludeLinks(doc, ranges, URUST_INCLUDE_PATTERNS).map(l => l.filename), + ['audio.rs', 'bcd.rs']); + // if only the subdirectory form exists, link to that one + assert.deepStrictEqual( + findIncludeLinks(doc, ranges, URUST_INCLUDE_PATTERNS, fn => fn === 'audio/mod.rs').map(l => l.filename), + ['audio/mod.rs', 'bcd.rs']); + }); + it('extracts Rust fn declarations as symbols', function () { const records = extractSymbols('fn draw(x: u8) {}\nfn main() {}\n', 'main.rs', 'rust', 'project'); assert.deepStrictEqual(records.filter(r => r.kind === 'func').map(r => r.name), ['draw', 'main']);