linguist-js 3.0.0-pre → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/analyser/pipeline/aggregate.d.ts +3 -1
  2. package/dist/analyser/pipeline/aggregate.js +8 -8
  3. package/dist/analyser/pipeline/classify.js +2 -1
  4. package/dist/src/cli/output/default.d.ts +3 -0
  5. package/dist/src/cli/output/default.js +106 -0
  6. package/dist/src/cli/output/tree.d.ts +3 -0
  7. package/dist/src/cli/output/tree.js +11 -0
  8. package/dist/src/cli/runCliAnalysis.d.ts +2 -0
  9. package/dist/src/cli/runCliAnalysis.js +25 -0
  10. package/dist/src/cli/utils.d.ts +2 -0
  11. package/dist/src/cli/utils.js +9 -0
  12. package/dist/src/cli.d.ts +1 -0
  13. package/dist/src/cli.js +56 -0
  14. package/dist/src/helpers/convert-pcre.d.ts +2 -0
  15. package/dist/src/helpers/convert-pcre.js +38 -0
  16. package/dist/src/helpers/load-data.d.ts +4 -0
  17. package/dist/src/helpers/load-data.js +37 -0
  18. package/dist/src/helpers/norm-path.d.ts +2 -0
  19. package/dist/src/helpers/norm-path.js +15 -0
  20. package/dist/src/helpers/parse-gitattributes.d.ts +17 -0
  21. package/dist/src/helpers/parse-gitattributes.js +37 -0
  22. package/dist/src/helpers/parse-gitignore.d.ts +1 -0
  23. package/dist/src/helpers/parse-gitignore.js +12 -0
  24. package/dist/src/helpers/read-file.d.ts +5 -0
  25. package/dist/src/helpers/read-file.js +23 -0
  26. package/dist/src/helpers/walk-tree.d.ts +20 -0
  27. package/dist/src/helpers/walk-tree.js +90 -0
  28. package/dist/src/index.d.ts +5 -0
  29. package/dist/src/index.js +470 -0
  30. package/dist/src/program/data/loadData.d.ts +4 -0
  31. package/dist/src/program/data/loadData.js +32 -0
  32. package/dist/src/program/fs/normalisedPath.d.ts +2 -0
  33. package/dist/src/program/fs/normalisedPath.js +7 -0
  34. package/dist/src/program/fs/readFile.d.ts +5 -0
  35. package/dist/src/program/fs/readFile.js +17 -0
  36. package/dist/src/program/fs/walkTree.d.ts +20 -0
  37. package/dist/src/program/fs/walkTree.js +82 -0
  38. package/dist/src/program/parsing/parseGitattributes.d.ts +17 -0
  39. package/dist/src/program/parsing/parseGitattributes.js +33 -0
  40. package/dist/src/program/parsing/parseGitignore.d.ts +1 -0
  41. package/dist/src/program/parsing/parseGitignore.js +9 -0
  42. package/dist/src/program/utils/pcre.d.ts +2 -0
  43. package/dist/src/program/utils/pcre.js +35 -0
  44. package/dist/src/schema.d.ts +37 -0
  45. package/dist/src/schema.js +2 -0
  46. package/dist/src/types/schema.d.ts +37 -0
  47. package/dist/src/types/schema.js +1 -0
  48. package/dist/src/types/types.d.ts +65 -0
  49. package/dist/src/types/types.js +1 -0
  50. package/dist/src/types.d.ts +74 -0
  51. package/dist/src/types.js +2 -0
  52. package/dist/types/types.d.ts +0 -1
  53. package/ext/languages.yml +8 -0
  54. package/package.json +2 -2
  55. package/readme.md +34 -41
@@ -0,0 +1,470 @@
1
+ import commonPrefix from 'common-path-prefix';
2
+ import ignore from 'ignore';
3
+ import { isBinaryFile } from 'isbinaryfile';
4
+ import YAML from 'js-yaml';
5
+ import FS from 'node:fs';
6
+ import Path from 'node:path';
7
+ import loadFile, { parseGeneratedDataFile } from './program/data/loadData.js';
8
+ import { normPath } from './program/fs/normalisedPath.js';
9
+ import readFileChunk from './program/fs/readFile.js';
10
+ import walkTree from './program/fs/walkTree.js';
11
+ import parseGitattributes from './program/parsing/parseGitattributes.js';
12
+ import pcre from './program/utils/pcre.js';
13
+ const binaryData = JSON.parse(FS.readFileSync(new URL('../node_modules/binary-extensions/binary-extensions.json', import.meta.url), 'utf-8'));
14
+ async function analyse(rawInput, opts = {}) {
15
+ const inputs = {
16
+ path: typeof rawInput === 'string' ? rawInput : null,
17
+ paths: Array.isArray(rawInput) ? rawInput : null,
18
+ content: typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : null,
19
+ };
20
+ const inputPaths = inputs.paths ?? (inputs.path ? [inputs.path] : null);
21
+ const inputContent = inputs.content;
22
+ const useRawContent = inputContent !== null;
23
+ const input = useRawContent ? Object.keys(inputContent) : (inputPaths ?? []);
24
+ // Normalise input option arguments
25
+ opts = {
26
+ calculateLines: opts.calculateLines ?? true, // default to true if unset
27
+ checkIgnored: !opts.quick,
28
+ checkDetected: !opts.quick,
29
+ checkAttributes: !opts.quick,
30
+ checkHeuristics: !opts.quick,
31
+ checkShebang: !opts.quick,
32
+ checkModeline: !opts.quick,
33
+ ...opts,
34
+ };
35
+ // Load data from github-linguist web repo
36
+ const langData = await loadFile('languages.yml', opts.offline).then(YAML.load);
37
+ const vendorData = await loadFile('vendor.yml', opts.offline).then(YAML.load);
38
+ const docData = await loadFile('documentation.yml', opts.offline).then(YAML.load);
39
+ const heuristicsData = await loadFile('heuristics.yml', opts.offline).then(YAML.load);
40
+ const generatedData = await loadFile('generated.rb', opts.offline).then(parseGeneratedDataFile);
41
+ const vendorPaths = [...vendorData, ...docData, ...generatedData];
42
+ // Setup main variables
43
+ const fileAssociations = {};
44
+ const extensions = {};
45
+ const globOverrides = {};
46
+ const results = {
47
+ files: { count: 0, bytes: 0, lines: { total: 0, content: 0 }, results: {}, alternatives: {} },
48
+ languages: { count: 0, bytes: 0, lines: { total: 0, content: 0 }, results: {} },
49
+ unknown: { count: 0, bytes: 0, lines: { total: 0, content: 0 }, extensions: {}, filenames: {} },
50
+ repository: {},
51
+ };
52
+ // Set a common root path so that vendor paths do not incorrectly match parent folders
53
+ const resolvedInput = input.map((path) => normPath(Path.resolve(path)));
54
+ const commonRoot = (input.length > 1 ? commonPrefix(resolvedInput) : resolvedInput[0]).replace(/\/?$/, '');
55
+ const relPath = (file) => (useRawContent ? file : normPath(Path.relative(commonRoot, file)));
56
+ const unRelPath = (file) => (useRawContent ? file : normPath(Path.resolve(commonRoot, file)));
57
+ // Other helper functions
58
+ const fileMatchesGlobs = (file, ...globs) => ignore().add(globs).ignores(relPath(file));
59
+ const filterOutIgnored = (files, ignored) => ignored.filter(files.map(relPath)).map(unRelPath);
60
+ //*PREPARE FILES AND DATA*//
61
+ // Prepare list of ignored files
62
+ const ignored = ignore();
63
+ ignored.add('.git/');
64
+ ignored.add(opts.ignoredFiles ?? []);
65
+ const regexIgnores = opts.keepVendored ? [] : vendorPaths.map((path) => RegExp(path, 'i'));
66
+ // Load file paths and folders
67
+ let files;
68
+ if (useRawContent) {
69
+ // Uses raw file content
70
+ files = input;
71
+ }
72
+ else {
73
+ // Uses directory on disc
74
+ const data = walkTree({ init: true, commonRoot, folderRoots: resolvedInput, folders: resolvedInput, ignored });
75
+ files = data.files;
76
+ }
77
+ // Fetch and normalise gitattributes data of all subfolders and save to metadata
78
+ const manualAttributes = {}; // Maps file globs to gitattribute boolean flags
79
+ const getFlaggedGlobs = (attr, val) => {
80
+ return Object.entries(manualAttributes)
81
+ .filter(([, attrs]) => attrs[attr] === val)
82
+ .map(([glob]) => glob);
83
+ };
84
+ const findAttrsForPath = (filePath) => {
85
+ const resultAttrs = {};
86
+ for (const glob in manualAttributes) {
87
+ if (ignore().add(glob).ignores(relPath(filePath))) {
88
+ const matchingAttrs = manualAttributes[glob];
89
+ for (const [attr, val] of Object.entries(matchingAttrs)) {
90
+ if (val !== null)
91
+ resultAttrs[attr] = val;
92
+ }
93
+ }
94
+ }
95
+ if (!JSON.stringify(resultAttrs)) {
96
+ return null;
97
+ }
98
+ return resultAttrs;
99
+ };
100
+ if (!useRawContent && opts.checkAttributes) {
101
+ const nestedAttrFiles = files.filter((file) => file.endsWith('.gitattributes'));
102
+ for (const attrFile of nestedAttrFiles) {
103
+ const relAttrFile = relPath(attrFile);
104
+ const relAttrFolder = Path.dirname(relAttrFile);
105
+ const contents = await readFileChunk(attrFile);
106
+ const parsed = parseGitattributes(contents, relAttrFolder);
107
+ for (const { glob, attrs } of parsed) {
108
+ manualAttributes[glob] = attrs;
109
+ }
110
+ }
111
+ }
112
+ // Remove files that are linguist-ignored via regex by default unless explicitly unignored in gitattributes
113
+ const filesToIgnore = [];
114
+ for (const file of files) {
115
+ const relFile = relPath(file);
116
+ const isRegexIgnored = regexIgnores.some((pattern) => pattern.test(relFile));
117
+ if (!isRegexIgnored) {
118
+ // Checking overrides is moot if file is not even marked as ignored by default
119
+ continue;
120
+ }
121
+ const fileAttrs = findAttrsForPath(file);
122
+ if (fileAttrs?.generated === false || fileAttrs?.vendored === false) {
123
+ // File is explicitly marked as *not* to be ignored
124
+ // do nothing
125
+ }
126
+ else {
127
+ filesToIgnore.push(file);
128
+ }
129
+ }
130
+ files = files.filter((file) => !filesToIgnore.includes(file));
131
+ // Apply vendor file path matches and filter out vendored files
132
+ if (!opts.keepVendored) {
133
+ // Get data of files that have been manually marked with metadata
134
+ const vendorTrueGlobs = [
135
+ ...getFlaggedGlobs('vendored', true),
136
+ ...getFlaggedGlobs('generated', true),
137
+ ...getFlaggedGlobs('documentation', true),
138
+ ];
139
+ const vendorFalseGlobs = [
140
+ ...getFlaggedGlobs('vendored', false),
141
+ ...getFlaggedGlobs('generated', false),
142
+ ...getFlaggedGlobs('documentation', false),
143
+ ];
144
+ // Set up glob ignore object to use for expanding globs to match files
145
+ const vendorTrueIgnore = ignore().add(vendorTrueGlobs);
146
+ const vendorFalseIgnore = ignore().add(vendorFalseGlobs);
147
+ // Remove all files marked as vendored by default
148
+ const excludedFiles = files.filter((file) => vendorPaths.some((pathPtn) => RegExp(pathPtn, 'i').test(relPath(file))));
149
+ files = files.filter((file) => !excludedFiles.includes(file));
150
+ // Re-add removed files that are overridden manually in gitattributes
151
+ const overriddenExcludedFiles = excludedFiles.filter((file) => vendorFalseIgnore.ignores(relPath(file)));
152
+ files.push(...overriddenExcludedFiles);
153
+ // Remove files explicitly marked as vendored in gitattributes
154
+ files = files.filter((file) => !vendorTrueIgnore.ignores(relPath(file)));
155
+ }
156
+ // Filter out binary files
157
+ if (!opts.keepBinary) {
158
+ // Filter out files that are binary by default
159
+ files = files.filter((file) => !binaryData.some((ext) => file.endsWith('.' + ext)));
160
+ // Filter out manually specified binary files
161
+ const binaryIgnored = ignore().add(getFlaggedGlobs('binary', true));
162
+ files = filterOutIgnored(files, binaryIgnored);
163
+ // Re-add files manually marked not as binary
164
+ const binaryUnignored = ignore().add(getFlaggedGlobs('binary', false));
165
+ const unignoredList = filterOutIgnored(files, binaryUnignored);
166
+ files.push(...unignoredList);
167
+ }
168
+ // Ignore specific languages
169
+ for (const lang of opts.ignoredLanguages ?? []) {
170
+ for (const key in langData) {
171
+ if (lang.toLowerCase() === key.toLowerCase()) {
172
+ delete langData[key];
173
+ break;
174
+ }
175
+ }
176
+ }
177
+ // Establish language overrides taken from gitattributes
178
+ const forcedLangs = Object.entries(manualAttributes).filter(([, attrs]) => attrs.language);
179
+ for (const [globPath, attrs] of forcedLangs) {
180
+ let forcedLang = attrs.language;
181
+ if (!forcedLang)
182
+ continue;
183
+ // If specified language is an alias, associate it with its full name
184
+ if (!langData[forcedLang]) {
185
+ const overrideLang = Object.entries(langData).find((entry) => entry[1].aliases?.includes(forcedLang.toLowerCase()));
186
+ if (overrideLang) {
187
+ forcedLang = overrideLang[0];
188
+ }
189
+ }
190
+ globOverrides[globPath] = forcedLang;
191
+ }
192
+ //*PARSE LANGUAGES*//
193
+ const addResult = (file, result) => {
194
+ if (!fileAssociations[file]) {
195
+ fileAssociations[file] = [];
196
+ extensions[file] = '';
197
+ }
198
+ // Set parent to result group if it is present
199
+ // Is nullish if either `opts.childLanguages` is set or if there is no group
200
+ const finalResult = (!opts.childLanguages && result && langData[result] && langData[result].group) || result;
201
+ if (!fileAssociations[file].includes(finalResult)) {
202
+ fileAssociations[file].push(finalResult);
203
+ }
204
+ extensions[file] = Path.extname(file).toLowerCase();
205
+ };
206
+ const definiteness = {};
207
+ const fromShebang = {};
208
+ fileLoop: for (const file of files) {
209
+ // Check manual override
210
+ for (const globMatch in globOverrides) {
211
+ if (!fileMatchesGlobs(file, globMatch))
212
+ continue;
213
+ // If the given file matches the glob, apply the override to the file
214
+ const forcedLang = globOverrides[globMatch];
215
+ addResult(file, forcedLang);
216
+ definiteness[file] = true;
217
+ continue fileLoop; // no need to check other heuristics, the classified language has been found
218
+ }
219
+ // Check first line for readability
220
+ let firstLine;
221
+ if (useRawContent) {
222
+ firstLine = inputContent[file]?.split('\n')[0] ?? null;
223
+ }
224
+ else if (FS.existsSync(file) && !FS.lstatSync(file).isDirectory()) {
225
+ firstLine = await readFileChunk(file, true).catch(() => null);
226
+ }
227
+ else
228
+ continue;
229
+ // Skip if file is unreadable or blank
230
+ if (firstLine === null)
231
+ continue;
232
+ // Check first line for explicit classification
233
+ const modelineRegex = /-\*-|(?:syntax|filetype|ft)\s*=/;
234
+ const hasShebang = opts.checkShebang && /^#!/.test(firstLine);
235
+ const hasModeline = opts.checkModeline && modelineRegex.test(firstLine);
236
+ if (!opts.quick && (hasShebang || hasModeline)) {
237
+ const matches = [];
238
+ for (const [lang, data] of Object.entries(langData)) {
239
+ const langMatcher = (lang) => `\\b${lang.toLowerCase().replace(/\W/g, '\\$&')}(?![\\w#+*]|-\*-)`;
240
+ // Check for interpreter match
241
+ if (opts.checkShebang && hasShebang) {
242
+ const matchesInterpretor = data.interpreters?.some((interpreter) => firstLine.match(`\\b${interpreter}\\b`));
243
+ if (matchesInterpretor)
244
+ matches.push(lang);
245
+ }
246
+ // Check modeline declaration
247
+ if (opts.checkModeline && hasModeline) {
248
+ const modelineText = firstLine.toLowerCase().split(modelineRegex)[1];
249
+ const matchesLang = modelineText.match(langMatcher(lang));
250
+ const matchesAlias = data.aliases?.some((lang) => modelineText.match(langMatcher(lang)));
251
+ if (matchesLang || matchesAlias)
252
+ matches.push(lang);
253
+ }
254
+ }
255
+ // Add identified language(s)
256
+ if (matches.length) {
257
+ for (const match of matches)
258
+ addResult(file, match);
259
+ if (matches.length === 1)
260
+ definiteness[file] = true;
261
+ fromShebang[file] = true;
262
+ continue;
263
+ }
264
+ }
265
+ // Search each language
266
+ let skipExts = false;
267
+ // Check if filename is a match
268
+ for (const lang in langData) {
269
+ const matchesName = langData[lang].filenames?.some((name) => Path.basename(file.toLowerCase()) === name.toLowerCase());
270
+ if (matchesName) {
271
+ addResult(file, lang);
272
+ skipExts = true;
273
+ }
274
+ }
275
+ // Check if extension is a match
276
+ const possibleExts = [];
277
+ if (!skipExts)
278
+ for (const lang in langData) {
279
+ const extMatches = langData[lang].extensions?.filter((ext) => file.toLowerCase().endsWith(ext.toLowerCase()));
280
+ if (extMatches?.length) {
281
+ for (const ext of extMatches)
282
+ possibleExts.push({ ext, lang });
283
+ }
284
+ }
285
+ // Apply more specific extension if available
286
+ const isComplexExt = (ext) => /\..+\./.test(ext);
287
+ const hasComplexExt = possibleExts.some((data) => isComplexExt(data.ext));
288
+ for (const { ext, lang } of possibleExts) {
289
+ if (hasComplexExt && !isComplexExt(ext))
290
+ continue;
291
+ if (!hasComplexExt && isComplexExt(ext))
292
+ continue;
293
+ addResult(file, lang);
294
+ }
295
+ // Fallback to null if no language matches
296
+ if (!fileAssociations[file]) {
297
+ addResult(file, null);
298
+ }
299
+ }
300
+ // Narrow down file associations to the best fit
301
+ for (const file in fileAssociations) {
302
+ // Skip if file has explicit association
303
+ if (definiteness[file]) {
304
+ results.files.results[file] = fileAssociations[file][0];
305
+ continue;
306
+ }
307
+ // Skip binary files
308
+ if (!useRawContent && !opts.keepBinary) {
309
+ if (await isBinaryFile(file))
310
+ continue;
311
+ }
312
+ // Parse heuristics if applicable
313
+ if (opts.checkHeuristics)
314
+ for (const heuristics of heuristicsData.disambiguations) {
315
+ // Make sure the extension matches the current file
316
+ if (!fromShebang[file] && !heuristics.extensions.includes(extensions[file]))
317
+ continue;
318
+ // Load heuristic rules
319
+ for (const heuristic of heuristics.rules) {
320
+ // Make sure the language is not an array
321
+ if (Array.isArray(heuristic.language)) {
322
+ heuristic.language = heuristic.language[0];
323
+ }
324
+ // Make sure the results includes this language
325
+ const languageGroup = langData[heuristic.language]?.group;
326
+ const matchesLang = fileAssociations[file].includes(heuristic.language);
327
+ const matchesParent = languageGroup && fileAssociations[file].includes(languageGroup);
328
+ if (!matchesLang && !matchesParent)
329
+ continue;
330
+ // Normalise heuristic data
331
+ const patterns = [];
332
+ const normalise = (contents) => patterns.push(...[contents].flat());
333
+ if (heuristic.pattern)
334
+ normalise(heuristic.pattern);
335
+ if (heuristic.named_pattern)
336
+ normalise(heuristicsData.named_patterns[heuristic.named_pattern]);
337
+ if (heuristic.and) {
338
+ for (const data of heuristic.and) {
339
+ if (data.pattern)
340
+ normalise(data.pattern);
341
+ if (data.named_pattern)
342
+ normalise(heuristicsData.named_patterns[data.named_pattern]);
343
+ }
344
+ }
345
+ // Check file contents and apply heuristic patterns
346
+ const fileContent = useRawContent ? inputContent[file] : await readFileChunk(file).catch(() => null);
347
+ // Skip if file read errors
348
+ if (fileContent === null)
349
+ continue;
350
+ // Apply heuristics
351
+ if (!patterns.length || patterns.some((pattern) => pcre(pattern).test(fileContent))) {
352
+ results.files.results[file] = heuristic.language;
353
+ break;
354
+ }
355
+ }
356
+ }
357
+ // If no heuristics, assign a language
358
+ if (!results.files.results[file]) {
359
+ const possibleLangs = fileAssociations[file];
360
+ // Assign first language as a default option
361
+ const defaultLang = possibleLangs[0];
362
+ const alternativeLangs = possibleLangs.slice(1);
363
+ results.files.results[file] = defaultLang;
364
+ // List alternative languages if there are any
365
+ if (alternativeLangs.length > 0)
366
+ results.files.alternatives[file] = alternativeLangs;
367
+ }
368
+ }
369
+ // Skip specified categories
370
+ if (opts.categories?.length) {
371
+ const categories = ['data', 'markup', 'programming', 'prose'];
372
+ const hiddenCategories = categories.filter((cat) => !opts.categories.includes(cat));
373
+ for (const [file, lang] of Object.entries(results.files.results)) {
374
+ // Skip if language is not hidden
375
+ if (!hiddenCategories.some((cat) => lang && langData[lang]?.type === cat))
376
+ continue;
377
+ // Skip if language is forced as detectable
378
+ if (opts.checkDetected) {
379
+ const detectable = ignore().add(getFlaggedGlobs('detectable', true));
380
+ if (detectable.ignores(relPath(file)))
381
+ continue;
382
+ }
383
+ // Delete result otherwise
384
+ delete results.files.results[file];
385
+ if (lang)
386
+ delete results.languages.results[lang];
387
+ }
388
+ for (const category of hiddenCategories) {
389
+ for (const [lang, { type }] of Object.entries(results.repository)) {
390
+ if (type === category) {
391
+ delete results.languages.results[lang];
392
+ }
393
+ }
394
+ }
395
+ }
396
+ // Convert paths to relative
397
+ if (!useRawContent && opts.relativePaths) {
398
+ const newMap = {};
399
+ for (const [file, lang] of Object.entries(results.files.results)) {
400
+ let relPath = normPath(Path.relative(process.cwd(), file));
401
+ if (!relPath.startsWith('../')) {
402
+ relPath = './' + relPath;
403
+ }
404
+ newMap[relPath] = lang;
405
+ }
406
+ results.files.results = newMap;
407
+ }
408
+ // Load language bytes size
409
+ for (const [file, lang] of Object.entries(results.files.results)) {
410
+ if (lang && !langData[lang])
411
+ continue;
412
+ // Calculate file size
413
+ const fileSize = useRawContent ? inputContent[file]?.length : FS.statSync(file).size;
414
+ // Calculate lines of code
415
+ const loc = { total: 0, content: 0 };
416
+ if (opts.calculateLines) {
417
+ const fileContent = useRawContent ? inputContent[file] : FS.readFileSync(file).toString();
418
+ const allLines = fileContent.split(/\r?\n/gm);
419
+ loc.total = allLines.length;
420
+ loc.content = allLines.filter((line) => line.trim().length > 0).length;
421
+ }
422
+ // Apply to files totals
423
+ results.files.bytes += fileSize;
424
+ results.files.lines.total += loc.total;
425
+ results.files.lines.content += loc.content;
426
+ // Add results to 'languages' section if language match found, or 'unknown' section otherwise
427
+ if (lang) {
428
+ // update language in repository if not yet present
429
+ if (!results.repository[lang]) {
430
+ const { type, color } = langData[lang];
431
+ results.repository[lang] = { type, color };
432
+ if (opts.childLanguages) {
433
+ results.repository[lang].parent = langData[lang].group;
434
+ }
435
+ }
436
+ // set default if unset
437
+ results.languages.results[lang] ??= { count: 0, bytes: 0, lines: { total: 0, content: 0 } };
438
+ // apply results to 'languages' section
439
+ results.languages.results[lang].count++;
440
+ results.languages.results[lang].bytes += fileSize;
441
+ results.languages.bytes += fileSize;
442
+ results.languages.results[lang].lines.total += loc.total;
443
+ results.languages.results[lang].lines.content += loc.content;
444
+ results.languages.lines.total += loc.total;
445
+ results.languages.lines.content += loc.content;
446
+ }
447
+ else {
448
+ const ext = Path.extname(file);
449
+ const unknownType = ext ? 'extensions' : 'filenames';
450
+ const name = ext || Path.basename(file);
451
+ // apply results to 'unknown' section
452
+ results.unknown[unknownType][name] ??= 0;
453
+ results.unknown[unknownType][name] += fileSize;
454
+ results.unknown.bytes += fileSize;
455
+ results.unknown.lines.total += loc.total;
456
+ results.unknown.lines.content += loc.content;
457
+ }
458
+ }
459
+ // Set lines output to NaN when line calculation is disabled
460
+ if (opts.calculateLines === false) {
461
+ results.files.lines = { total: NaN, content: NaN };
462
+ }
463
+ // Set counts
464
+ results.files.count = Object.keys(results.files.results).length;
465
+ results.languages.count = Object.keys(results.languages.results).length;
466
+ results.unknown.count = Object.keys({ ...results.unknown.extensions, ...results.unknown.filenames }).length;
467
+ // Return
468
+ return results;
469
+ }
470
+ export default analyse;
@@ -0,0 +1,4 @@
1
+ /** Nukes unused `generated.rb` file content. */
2
+ export declare function parseGeneratedDataFile(fileContent: string): string[];
3
+ /** Load a data file from github-linguist. */
4
+ export default function loadFile(file: string, offline?: boolean): Promise<string>;
@@ -0,0 +1,32 @@
1
+ import Cache from 'node-cache';
2
+ import FS from 'node:fs';
3
+ import Path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const cache = new Cache({});
6
+ const dirname = Path.dirname(fileURLToPath(import.meta.url));
7
+ async function loadWebFile(file) {
8
+ // Return cache if it exists
9
+ const cachedContent = cache.get(file);
10
+ if (cachedContent)
11
+ return cachedContent;
12
+ // Otherwise cache the request
13
+ const dataUrl = (file) => `https://raw.githubusercontent.com/github/linguist/HEAD/lib/linguist/${file}`;
14
+ // Load file content, falling back to the local file if the request fails
15
+ const fileContent = await fetch(dataUrl(file))
16
+ .then((data) => data.text())
17
+ .catch(async () => await loadLocalFile(file));
18
+ cache.set(file, fileContent);
19
+ return fileContent;
20
+ }
21
+ async function loadLocalFile(file) {
22
+ const filePath = Path.resolve(dirname, '../../ext', file);
23
+ return FS.promises.readFile(filePath).then((buffer) => buffer.toString());
24
+ }
25
+ /** Nukes unused `generated.rb` file content. */
26
+ export function parseGeneratedDataFile(fileContent) {
27
+ return [...(fileContent.match(/(?<=name\.match\(\/).+?(?=(?<!\\)\/)/gm) ?? [])];
28
+ }
29
+ /** Load a data file from github-linguist. */
30
+ export default function loadFile(file, offline = false) {
31
+ return offline ? loadLocalFile(file) : loadWebFile(file);
32
+ }
@@ -0,0 +1,2 @@
1
+ export declare const normPath: (...inputPaths: string[]) => string;
2
+ export declare const normAbsPath: (...inputPaths: string[]) => string;
@@ -0,0 +1,7 @@
1
+ import Path from 'node:path';
2
+ export const normPath = function normalisedPath(...inputPaths) {
3
+ return Path.join(...inputPaths).replace(/\\/g, '/');
4
+ };
5
+ export const normAbsPath = function normalisedAbsolutePath(...inputPaths) {
6
+ return Path.resolve(...inputPaths).replace(/\\/g, '/');
7
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Read part of a file on disc.
3
+ * @throws 'EPERM' if the file is not readable.
4
+ */
5
+ export default function readFileChunk(filename: string, onlyFirstLine?: boolean): Promise<string>;
@@ -0,0 +1,17 @@
1
+ import FS from 'node:fs';
2
+ /**
3
+ * Read part of a file on disc.
4
+ * @throws 'EPERM' if the file is not readable.
5
+ */
6
+ export default async function readFileChunk(filename, onlyFirstLine = false) {
7
+ const chunkSize = 100;
8
+ const stream = FS.createReadStream(filename, { highWaterMark: chunkSize });
9
+ let content = '';
10
+ for await (const data of stream) { // may throw
11
+ content += data.toString();
12
+ if (onlyFirstLine && content.includes('\n')) {
13
+ return content.split(/\r?\n/)[0];
14
+ }
15
+ }
16
+ return content;
17
+ }
@@ -0,0 +1,20 @@
1
+ import { Ignore } from 'ignore';
2
+ interface WalkInput {
3
+ /** Whether this is walking the tree from the root */
4
+ init: boolean;
5
+ /** The common root absolute path of all folders being checked */
6
+ commonRoot: string;
7
+ /** The absolute path that each folder is relative to */
8
+ folderRoots: string[];
9
+ /** The absolute path of folders being checked */
10
+ folders: string[];
11
+ /** An instantiated Ignore object listing ignored files */
12
+ ignored: Ignore;
13
+ }
14
+ interface WalkOutput {
15
+ files: string[];
16
+ folders: string[];
17
+ }
18
+ /** Generate list of files in a directory. */
19
+ export default function walkTree(data: WalkInput): WalkOutput;
20
+ export {};
@@ -0,0 +1,82 @@
1
+ import FS from 'node:fs';
2
+ import Path from 'node:path';
3
+ import parseGitignore from '../parsing/parseGitignore.js';
4
+ import { normAbsPath, normPath } from './normalisedPath.js';
5
+ let allFiles;
6
+ let allFolders;
7
+ /** Generate list of files in a directory. */
8
+ export default function walkTree(data) {
9
+ const { init, commonRoot, folderRoots, folders, ignored } = data;
10
+ // Initialise files and folders lists
11
+ if (init) {
12
+ allFiles = new Set();
13
+ allFolders = new Set();
14
+ }
15
+ // Walk tree of a folder
16
+ if (folders.length === 1) {
17
+ const folder = folders[0];
18
+ const localRoot = folderRoots[0].replace(commonRoot, '').replace(/^\//, '');
19
+ // Get list of files and folders inside this folder
20
+ const files = FS.readdirSync(folder).map((file) => {
21
+ // Create path relative to root
22
+ const base = normAbsPath(folder, file).replace(commonRoot, '.');
23
+ // Add trailing slash to mark directories
24
+ const isDir = FS.lstatSync(Path.resolve(commonRoot, base)).isDirectory();
25
+ return isDir ? `${base}/` : base;
26
+ });
27
+ // Read and apply gitignores
28
+ const gitignoreFilename = normPath(folder, '.gitignore');
29
+ if (FS.existsSync(gitignoreFilename)) {
30
+ const gitignoreContents = FS.readFileSync(gitignoreFilename, 'utf-8');
31
+ const ignoredPaths = parseGitignore(gitignoreContents);
32
+ const rootRelIgnoredPaths = ignoredPaths.map((ignorePath) =>
33
+ // get absolute path of the ignore glob
34
+ normPath(folder, ignorePath)
35
+ // convert abs ignore glob to be relative to the root folder
36
+ .replace(commonRoot + '/', ''));
37
+ ignored.add(rootRelIgnoredPaths);
38
+ }
39
+ // Add gitattributes if present
40
+ const gitattributesPath = normPath(folder, '.gitattributes');
41
+ if (FS.existsSync(gitattributesPath)) {
42
+ allFiles.add(gitattributesPath);
43
+ }
44
+ // Loop through files and folders
45
+ for (const file of files) {
46
+ // Create absolute path for disc operations
47
+ const path = normAbsPath(commonRoot, file);
48
+ const localPath = localRoot ? file.replace(`./${localRoot}/`, '') : file.replace('./', '');
49
+ // Skip if nonexistant
50
+ const nonExistant = !FS.existsSync(path);
51
+ if (nonExistant)
52
+ continue;
53
+ // Skip if marked in gitignore
54
+ const isIgnored = ignored.test(localPath).ignored;
55
+ if (isIgnored)
56
+ continue;
57
+ // Add absolute folder path to list
58
+ allFolders.add(normAbsPath(folder));
59
+ // Check if this is a folder or file
60
+ if (file.endsWith('/')) {
61
+ // Recurse into subfolders
62
+ allFolders.add(path);
63
+ walkTree({ init: false, commonRoot, folderRoots, folders: [path], ignored });
64
+ }
65
+ else {
66
+ // Add file path to list
67
+ allFiles.add(path);
68
+ }
69
+ }
70
+ }
71
+ // Recurse into all folders
72
+ else {
73
+ for (const i in folders) {
74
+ walkTree({ init: false, commonRoot, folderRoots: [folderRoots[i]], folders: [folders[i]], ignored });
75
+ }
76
+ }
77
+ // Return absolute files and folders lists
78
+ return {
79
+ files: [...allFiles].map((file) => file.replace(/^\./, commonRoot)),
80
+ folders: [...allFolders],
81
+ };
82
+ }
@@ -0,0 +1,17 @@
1
+ import * as T from '../../types/types.js';
2
+ export type FlagAttributes = {
3
+ 'vendored': boolean | null;
4
+ 'generated': boolean | null;
5
+ 'documentation': boolean | null;
6
+ 'detectable': boolean | null;
7
+ 'binary': boolean | null;
8
+ 'language': T.LanguageResult;
9
+ };
10
+ export type ParsedGitattributes = Array<{
11
+ glob: T.FileGlob;
12
+ attrs: FlagAttributes;
13
+ }>;
14
+ /**
15
+ * Parses a gitattributes file.
16
+ */
17
+ export default function parseGitattributes(content: string, folderRoot?: string): ParsedGitattributes;