Skip to content

Commit

Permalink
feat: suggest correct paths based on levenshtein distance
Browse files Browse the repository at this point in the history
  • Loading branch information
tlouisse committed Jun 15, 2022
1 parent 8c76c2d commit 096a1d1
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/gentle-crabs-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'check-html-links': minor
---

suggest correct paths based on levenshtein distance
2 changes: 1 addition & 1 deletion packages/check-html-links/src/CheckHtmlLinksCli.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class CheckHtmlLinksCli {
)} missing reference targets (used by ${referenceCount} links) while checking ${
files.length
} files:`,
...formatErrors(errors)
...formatErrors(errors, { files })
.split('\n')
.map(line => ` ${line}`),
`Checking links duration: ${performance[0]}s ${performance[1] / 1000000}ms`,
Expand Down
27 changes: 25 additions & 2 deletions packages/check-html-links/src/formatErrors.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import path from 'path';
import chalk from 'chalk';
import levenshtein from './levenshtein.js';

/** @typedef {import('../types/main').Error} Error */

/**
* @param {Error[]} errors
* @param {*} relativeFrom
* @param {{ relativeFrom?: string; files: string[] }} opts
*/
export function formatErrors(errors, relativeFrom = process.cwd()) {
// @ts-expect-error we need empty obj to destructure from
export function formatErrors(errors, { relativeFrom = process.cwd(), files } = {}) {
let output = [];
let number = 0;
for (const error of errors) {
Expand Down Expand Up @@ -41,6 +43,27 @@ export function formatErrors(errors, relativeFrom = process.cwd()) {
output.push(` ... ${more} more references to this target`);
}
output.push('');

/**
* Also consider finding the updated path. This can be useful when documentation is restructured
* For instance, the folder name was changed, but the file name was not.
*/
let suggestion;
let lowestScore = -1;
files.forEach(file => {
const filePathToCompare = file.replace(relativeFrom + '/', '');
const score = levenshtein(filePathToCompare, filePath);
if (score && (lowestScore === -1 || score < lowestScore)) {
lowestScore = score;
suggestion = filePathToCompare;
}
});

if (suggestion) {
output.push(
chalk.italic(`Suggestion: did you mean ${chalk.magenta(suggestion)} instead?\n\n`),
);
}
}
return output.join('\n');
}
107 changes: 107 additions & 0 deletions packages/check-html-links/src/levenshtein.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/* eslint-disable */
// https://github.com/gustf/js-levenshtein/blob/master/index.js

/**
* @param {number} d0
* @param {number} d1
* @param {number} d2
* @param {any} bx
* @param {any} ay
*/
function _min(d0, d1, d2, bx, ay) {
return d0 < d1 || d2 < d1 ? (d0 > d2 ? d2 + 1 : d0 + 1) : bx === ay ? d1 : d1 + 1;
}

/**
* @param {string} a
* @param {string} b
* @returns {number|undefined}
*/
export default function (a, b) {
if (a === b) {
return 0;
}

if (a.length > b.length) {
var tmp = a;
a = b;
b = tmp;
}

var la = a.length;
var lb = b.length;

while (la > 0 && a.charCodeAt(la - 1) === b.charCodeAt(lb - 1)) {
la--;
lb--;
}

var offset = 0;

while (offset < la && a.charCodeAt(offset) === b.charCodeAt(offset)) {
offset++;
}

la -= offset;
lb -= offset;

if (la === 0 || lb < 3) {
return lb;
}

var x = 0;
var y;
var d0;
var d1;
var d2;
var d3;
var dd;
var dy;
var ay;
var bx0;
var bx1;
var bx2;
var bx3;

var vector = [];

for (y = 0; y < la; y++) {
vector.push(y + 1);
vector.push(a.charCodeAt(offset + y));
}

var len = vector.length - 1;

for (; x < lb - 3; ) {
bx0 = b.charCodeAt(offset + (d0 = x));
bx1 = b.charCodeAt(offset + (d1 = x + 1));
bx2 = b.charCodeAt(offset + (d2 = x + 2));
bx3 = b.charCodeAt(offset + (d3 = x + 3));
dd = x += 4;
for (y = 0; y < len; y += 2) {
dy = vector[y];
ay = vector[y + 1];
d0 = _min(dy, d0, d1, bx0, ay);
d1 = _min(d0, d1, d2, bx1, ay);
d2 = _min(d1, d2, d3, bx2, ay);
dd = _min(d2, d3, dd, bx3, ay);
vector[y] = dd;
d3 = d2;
d2 = d1;
d1 = d0;
d0 = dy;
}
}

for (; x < lb; ) {
bx0 = b.charCodeAt(offset + (d0 = x));
dd = ++x;
for (y = 0; y < len; y += 2) {
dy = vector[y];
vector[y] = dd = _min(dy, d0, dd, bx0, vector[y + 1]);
d0 = dy;
}
}

return dd;
}
24 changes: 21 additions & 3 deletions packages/check-html-links/test-node/formatErrors.test.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import chai from 'chai';
import chalk from 'chalk';
import { execute } from './test-helpers.js';
import { formatErrors } from 'check-html-links';
import path from 'path';
import { fileURLToPath } from 'url';
import { execute } from './test-helpers.js';
import { listFiles } from '../src/listFiles.js';

const { expect } = chai;
const __dirname = path.dirname(fileURLToPath(import.meta.url));

async function executeAndFormat(inPath) {
const { errors, cleanup } = await execute(inPath);
return formatErrors(cleanup(errors));
const testDir = path.join(__dirname, inPath.split('/').join(path.sep));
const rootDir = path.resolve(testDir);
const files = await listFiles('**/*.html', rootDir);
return formatErrors(cleanup(errors), { files });
}

describe('formatErrors', () => {
Expand All @@ -16,23 +23,34 @@ describe('formatErrors', () => {
chalk.level = 0;
});

it('prints a nice summery', async () => {
it('prints a nice summary', async () => {
const result = await executeAndFormat('fixtures/test-case');
expect(result.trim().split('\n')).to.deep.equal([
'1. missing id="my-teams" in fixtures/test-case/price/index.html',
' from fixtures/test-case/history/index.html:1:9 via href="/price/#my-teams"',
'',
'Suggestion: did you mean test-node/fixtures/test-case/price/index.html instead?',
'',
'',
'2. missing file fixtures/test-case/about/images/team.png',
' from fixtures/test-case/about/index.html:3:10 via src="./images/team.png"',
'',
'Suggestion: did you mean test-node/fixtures/test-case/about/index.html instead?',
'',
'',
'3. missing reference target fixtures/test-case/aboot',
' from fixtures/test-case/about/index.html:6:11 via href="/aboot"',
' from fixtures/test-case/history/index.html:4:11 via href="/aboot"',
' from fixtures/test-case/index.html:4:11 via href="/aboot"',
' ... 2 more references to this target',
'',
'Suggestion: did you mean test-node/fixtures/test-case/index.html instead?',
'',
'',
'4. missing reference target fixtures/test-case/prce',
' from fixtures/test-case/index.html:1:9 via href="./prce"',
'',
'Suggestion: did you mean test-node/fixtures/test-case/index.html instead?',
]);
});
});

0 comments on commit 096a1d1

Please sign in to comment.