Add doc-comment extraction

- find-file-doc-comments.pl: new file
- data.py: Add database to store doc-comment locations
- script.sh: Add parse-docs subcommand
- update.py:
  - Add code to process doc comments
  - Update some variable names in hopes of reducing confusion
- query.py:
  - Add code to report doc comments
  - Update some variable names in hopes of reducing confusion

Also:
- t/TestEnvironment.pm: Add find_doc attribute
- t/interact.pl: Don't die if update.py fails
- t/TestHelpers.pm: Permit checking specific sections of query.py output
- t/300: update regexes per the preceding
- gitignore tags (ctags output) and .cache (api_test.py output)
This commit is contained in:
Christopher White 2020-04-02 13:03:03 -04:00
parent 60f206c9fa
commit 8a6031c8a1
10 changed files with 394 additions and 68 deletions

5
.gitignore vendored
View file

@ -1,4 +1,9 @@
# Generated files
__pycache__
tags
/.cache/
# Web-specific
http/images
http/*.html
http/favicon.ico

11
data.py
View file

@ -49,6 +49,8 @@ defTypeD = {v: k for k, v in defTypeR.items()}
maxId = 999999999
class DefList:
'''Stores associations between a blob ID, a type (e.g., "function"),
and a line number.'''
def __init__(self, data=b''):
self.data = data
@ -75,6 +77,8 @@ class DefList:
return self.data
class PathList:
'''Stores associations between a blob ID and a file path.
Inserted by update.py sorted by blob ID.'''
def __init__(self, data=b''):
self.data = data
@ -96,6 +100,7 @@ class PathList:
return self.data
class RefList:
'''Stores a mapping from blob ID to list of lines.'''
def __init__(self, data=b''):
self.data = data
@ -162,9 +167,15 @@ class DB:
ro = readonly
self.vars = BsdDB(dir + '/variables.db', ro, lambda x: int(x.decode()) )
# Key-value store of basic information
self.blob = BsdDB(dir + '/blobs.db', ro, lambda x: int(x.decode()) )
# Map hash to sequential integer serial number
self.hash = BsdDB(dir + '/hashes.db', ro, lambda x: x )
# Map serial number back to hash
self.file = BsdDB(dir + '/filenames.db', ro, lambda x: x.decode() )
# Map serial number to filename
self.vers = BsdDB(dir + '/versions.db', ro, PathList)
self.defs = BsdDB(dir + '/definitions.db', ro, DefList)
self.refs = BsdDB(dir + '/references.db', ro, RefList)
self.docs = BsdDB(dir + '/doccomments.db', ro, RefList)
# Use a RefList in case there are multiple doc comments for an identifier

100
find-file-doc-comments.pl Executable file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env perl
# find-file-doc-comments.pl: Find the doc comments for a file.
# Usage: find-file-doc-comments.pl <C source file name>
# By Christopher White <cwhite@d3engineering.com>
# Copyright (c) 2019 D3 Engineering, LLC.
# Licensed AGPLv3
use 5.010001;
use strict;
use warnings;
use autodie;
my $VERBOSE = $ENV{V};
exit main(@ARGV);
sub main {
die "Need a filename" unless @_;
# Do `script.sh parse-defs` on the file
my @ctags = qx{ ctags -x --c-kinds=+p-m --language-force=C "$_[0]" |
grep -av "^operator " |
awk '{print \$1" "\$2" "\$3}' };
die "Could not get ctags: $!" if $!;
print "No ctags results" if $VERBOSE && !@ctags;
return 0 unless @ctags;
# Make a list of [name, type, line] arrays
my @ctags_parsed = map { [split] } @ctags;
# Flip it around to index functions by line
my %function_lines;
for my $tag (@ctags_parsed) {
next unless $tag->[1] eq 'function';
$function_lines{$tag->[2]} = $tag->[0];
}
if($VERBOSE) {
for my $tag (@ctags_parsed) {
say $tag->[2], ': ', $tag->[0], ' is a(n) ', $tag->[1];
}
}
# Read the source file
open my $fh, '<', $_[0];
my @source_lines = (undef, <$fh>);
# undef => indices in @source_lines match ctags's 1-based linenos
close $fh;
# Work backwards through the file and look for doc comments
my %doc_comments;
my $doc_comment_opener = qr{^\s*\/\*\*(?:\s|$)}; # Start of doc comment
for(my $lineno = $#source_lines ; $lineno >= 1 ; --$lineno) {
next unless exists $function_lines{$lineno};
my $func_name = $function_lines{$lineno};
print "Checking for $func_name @ $lineno\n" if $VERBOSE;
my $this_doc_comment_header =
qr{^\s+\*\s+\Q$func_name\E(?:\s|\(|$)};
print " Regex is -$this_doc_comment_header-\n" if $VERBOSE;
--$lineno;
print " Line $lineno is: $source_lines[$lineno]\n" if $VERBOSE;
# Find the last line that could be a doc-comment header
# for this function.
while($source_lines[$lineno] =~
qr{
^\s*$ # Empty line
| ^\s+\*\/ # End of comment
| ^\s+\*(?:\s|$) # Continuation of comment
| $this_doc_comment_header
}x) {
print "$source_lines[$lineno] passed\n" if $VERBOSE;
--$lineno;
}
++$lineno; # Check the last line that matched,
# because we may have just skipped past $this_doc_comment_header
# Is it actually a header for this function?
print "Checking $source_lines[$lineno] for header\n" if $VERBOSE;
next unless $source_lines[$lineno] =~ $this_doc_comment_header;
# We have found a header. Confirm it's a doc comment.
--$lineno;
next unless $source_lines[$lineno] =~ $doc_comment_opener;
print " * Match\n" if $VERBOSE;
# We have found a doc comment for this function! Record it.
push @{$doc_comments{$func_name}}, $lineno;
}
# Report the doc comments for each function
while(my ($funcname, $comment_lines) = each %doc_comments) {
print "$funcname $_\n" foreach @$comment_lines;
}
return 0;
}

View file

@ -146,37 +146,60 @@ def query(cmd, *args):
symbol_definitions = []
symbol_references = []
symbol_doccomments = []
if not db.defs.exists(ident):
return symbol_definitions, symbol_references
return symbol_definitions, symbol_references, symbol_doccomments
if not db.vers.exists(version):
return symbol_definitions, symbol_references
return symbol_definitions, symbol_references, symbol_doccomments
vers = db.vers.get(version).iter()
defs = db.defs.get(ident).iter(dummy=True)
# FIXME: see why we can have a discrepancy between defs and refs
files_this_version = db.vers.get(version).iter()
defs_this_ident = db.defs.get(ident).iter(dummy=True)
# FIXME: see why we can have a discrepancy between defs_this_ident and refs
if db.refs.exists(ident):
refs = db.refs.get(ident).iter(dummy=True)
else:
refs = data.RefList().iter(dummy=True)
id2, type, dline = next(defs)
id3, rlines = next(refs)
if db.docs.exists(ident):
docs = db.docs.get(ident).iter(dummy=True)
else:
docs = data.RefList().iter(dummy=True)
# vers, defs, refs, and docs are all populated by update.py in order of
# idx, and there is a one-to-one mapping between blob hashes and idx
# values. Therefore, we can sequentially step through the defs, refs,
# and docs for each file in a version.
def_idx, def_type, def_line = next(defs_this_ident)
ref_idx, ref_lines = next(refs)
doc_idx, doc_line = next(docs)
dBuf = []
rBuf = []
docBuf = []
for file_idx, file_path in files_this_version:
# Advance defs, refs, and docs to the current file
while def_idx < file_idx:
def_idx, def_type, def_line = next(defs_this_ident)
while ref_idx < file_idx:
ref_idx, ref_lines = next(refs)
while doc_idx < file_idx:
doc_idx, doc_line = next(docs)
# Copy information about this identifier into dBuf, rBuf, and docBuf.
while def_idx == file_idx:
dBuf.append((file_path, def_type, def_line))
def_idx, def_type, def_line = next(defs_this_ident)
if ref_idx == file_idx:
rBuf.append((file_path, ref_lines))
if doc_idx == file_idx: # TODO should this be a `while`?
docBuf.append((file_path, doc_line))
for id1, path in vers:
while id1 > id2:
id2, type, dline = next(defs)
while id1 > id3:
id3, rlines = next(refs)
while id1 == id2:
dBuf.append((path, type, dline))
id2, type, dline = next(defs)
if id1 == id3:
rBuf.append((path, rlines))
for path, type, dline in sorted(dBuf):
symbol_definitions.append(SymbolInstance(path, dline, type))
@ -184,13 +207,16 @@ def query(cmd, *args):
for path, rlines in sorted(rBuf):
symbol_references.append(SymbolInstance(path, rlines))
return symbol_definitions, symbol_references
for path, docline in sorted(docBuf):
symbol_doccomments.append(SymbolInstance(path, docline))
return symbol_definitions, symbol_references, symbol_doccomments
else:
return('Unknown subcommand: ' + cmd + '\n')
def cmd_ident(version, ident, **kwargs):
symbol_definitions, symbol_references = query("ident", version, ident)
symbol_definitions, symbol_references, symbol_doccomments = query("ident", version, ident)
print("Symbol Definitions:")
for symbol_definition in symbol_definitions:
print(symbol_definition)
@ -199,6 +225,10 @@ def cmd_ident(version, ident, **kwargs):
for symbol_reference in symbol_references:
print(symbol_reference)
print("\nDocumented in:")
for symbol_doccomment in symbol_doccomments:
print(symbol_doccomment)
def cmd_file(version, path, **kwargs):
code = query("file", version, path)
print(code)

View file

@ -2,8 +2,8 @@
# This file is part of Elixir, a source code cross-referencer.
#
# Copyright (C) 2017 Mikaël Bouillot
# <mikael.bouillot@bootlin.com>
# Copyright (C) 2017--2020 Mikaël Bouillot
# <mikael.bouillot@bootlin.com> and contributors
#
# Elixir is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@ -23,6 +23,13 @@ if [ ! -d "$LXR_REPO_DIR" ]; then
exit 1
fi
# Get our path so we can find peer find-file-doc-comments.pl later
cur_dir=`pwd`
script_path=`realpath "$0"`
cd `dirname "$script_path"`
script_dir=`pwd`
cd "$cur_dir"
version_dir()
{
cat;
@ -140,6 +147,16 @@ parse_defs()
rmdir $tmp
}
parse_docs()
{
tmpfile=`mktemp`
git cat-file blob "$opt1" > "$tmpfile"
"$script_dir/find-file-doc-comments.pl" "$tmpfile"
rm -rf "$tmpfile"
}
project=$(basename `dirname $LXR_REPO_DIR`)
plugin=projects/$project.sh
@ -208,6 +225,10 @@ case $cmd in
parse_defs
;;
parse-docs)
parse_docs
;;
help)
echo "Usage: $0 subcommand [args]..."
exit 1

View file

@ -48,7 +48,15 @@ run_produces_ok('doc-comment query (nonexistent)',
[$tenv->query_py, qw(v5.4 ident SOME_NONEXISTENT_IDENTIFIER_XYZZY_PLUGH)],
[
qr{^Documented in:},
{ not => qr{/} } # No file paths in the output
{doc => { not => qr{/} }}, # No file paths in the doc section
],
MUST_SUCCEED);
run_produces_ok('doc-comment query (existent but not documented)',
[$tenv->query_py, qw(v5.4 ident gsb_buffer)], # in drivers/i2c/i2c-core-acpi.c
[
qr{^Documented in:},
{doc => { not => qr{/} }}
],
MUST_SUCCEED);
@ -56,7 +64,7 @@ run_produces_ok('ident query (existent, function, documented in C file)',
[$tenv->query_py, qw(v5.4 ident i2c_acpi_get_i2c_resource)],
[
qr{^Documented in:},
qr{drivers/i2c/i2c-core-acpi\.c.+\b45\b},
{doc => qr{drivers/i2c/i2c-core-acpi\.c.+\b45\b}},
],
MUST_SUCCEED);

View file

@ -69,6 +69,10 @@ As L</script_sh>, but for C<query.py>.
As L</script_sh>, but for C<update.py>.
=head2 find_doc
As L</script_sh>, but for C<find-file-doc-comments.pl>.
=cut
has lxr_data_dir => ();
@ -82,6 +86,9 @@ has query_py => (
has update_py => (
default => sub { find_program('update.py') }
);
has find_doc => (
default => sub { find_program('find-file-doc-comments.pl') }
);
# Internal attributes
@ -204,11 +211,12 @@ Returns a human-readable report of the current environment's state.
sub report {
my $self = shift;
return <<EOT;
Repository: @{[$self->lxr_repo_dir]}
Database: @{[$self->lxr_data_dir]}
script.sh: @{[$self->script_sh]}
update.py: @{[$self->update_py]}
query.py: @{[$self->query_py]}
Repository: @{[$self->lxr_repo_dir || '<unknown>']}
Database: @{[$self->lxr_data_dir || '<unknown>']}
script.sh: @{[$self->script_sh || '<unknown>']}
update.py: @{[$self->update_py || '<unknown>']}
query.py: @{[$self->query_py || '<unknown>']}
find-file-doc-comments.pl: @{[$self->find_doc || '<unknown>']}
EOT
} #report()

View file

@ -48,7 +48,7 @@ BEGIN {
%EXPORT_TAGS = (
all => [@EXPORT, @EXPORT_OK],
);
}
} #BEGIN
# Forwards for internal functions
sub line_mark_string;
@ -80,7 +80,7 @@ this file. Usage:
sub sibling_abs_path {
return File::Spec->rel2abs(File::Spec->catfile($FindBin::Bin, @_));
}
} #sibling_abs_path()
=head2 find_program
@ -157,12 +157,47 @@ Usage:
run_produces_ok($desc, \@program_and_args, \@expected_regexes,
<optional> $mustSucceed, <optional> $printOutput)
The test passes if each regex in C<@expected_regexes> matches at least one
line in the output of C<@program_and_args>, and if each C<< { not => regex } >>
in C<@expected_regexes> is NOT found in that output.
The test passes if each condition in C<@conditions> is true.
If C<$mustSucceed> is true, also tests for exit status 0 and empty stderr.
If C<$printOutput> is true, prints the output of C<@program_and_args>.
=head3 Conditions that can be used any time
=over
=item *
A regex: true if the regex matches at least one line in the output of
C<@program_and_args>
=item *
C<< { not => regex } >>: true if the regex is NOT found in any line of
the output of C<@program_and_args>.
=back
=head3 Conditions for the output of C<query.py>
=over
=item *
C<< { def => regex } >>: true if the regex matches at least one line in
the "Symbol Definitions" section of the output of C<@program_and_args>.
=item *
C<< { ref => regex } >>: true if the regex matches at least one line in
the "Symbol References" section of the output of C<@program_and_args>.
=item *
C<< { doc => regex } >>: true if the regex matches at least one line in
the "Documented in" section of the output of C<@program_and_args>.
=back
=cut
sub run_produces_ok {
@ -211,20 +246,29 @@ EOT
}
# Check regexes
my %query_py_output; # filled in only if we see a def/ref/doc
for my $entry (@$lrRegexes) {
my ($re, $negated, $source) = _parse_condition($entry);
if(ref $entry eq 'Regexp') {
eval line_mark_string 1,
q(ok( (grep { m{$entry} } @outlines), "$desc: output includes $entry" ));
} elsif(ref $entry eq 'HASH' && ref $entry->{not} eq 'Regexp') {
my $re = $entry->{not};
eval line_mark_string 1,
q(ok( !(grep { m{$re} } @outlines), "$desc: output excludes $re" ));
# Parse query.py output if we need it and haven't done so
%query_py_output = _parseq(@outlines)
if $source ne 'output' && !%query_py_output;
# Build a line of test code to run
my $test = 'ok( ';
$test .= '!' if $negated;
$test .= '(grep { m{$re} } ';
if($source eq 'output') {
$test .= '@outlines';
} else {
die "Invalid entry $entry";
$test .= '@{$query_py_output{' . $source . '}}';
}
$test .= '), "$desc: ' . $source;
$test .= ($negated ? ' excludes ' : ' includes ') . "\Q$re\E" . '");';
# Run it
#diag "Running $test";
eval line_mark_string 1, $test;
} #foreach $entry
} #run_produces_ok()
@ -233,16 +277,75 @@ EOT
These are ones you probably won't need to call.
=head2 _parseq
Parse the output of query.py. Usage:
%parsed = _parseq(@lines_of_output);
=cut
sub _parseq {
my %retval = { def => [], ref => [], doc => [] };
my $list;
foreach(@_) {
chomp;
if($_ eq 'Symbol Definitions:') {
$list = 'def';
next;
} elsif($_ eq 'Symbol References:') {
$list = 'ref';
next;
} elsif($_ eq 'Documented in:') {
$list = 'doc';
next;
}
#diag "Adding `$_' to list $list";
push @{$retval{$list}}, $_;
}
return %retval;
} #_parseq()
=head2 _parse_condition
Parse a condition for L</run_produces_ok>. Usage:
($regex, $negated, $source) = _parse_condition($entry[, $source]);
=cut
sub _parse_condition {
my ($entry, $source_in) = @_;
my ($regex, $negated, $source); # Return values
# Basic cases
if(ref $entry eq 'Regexp') {
return ($entry, 0, $source_in || 'output');
} elsif(ref $entry eq 'HASH' && ref $entry->{not} eq 'Regexp') {
return ($entry->{not}, 1, $source_in || 'output');
}
# Sub-keys: chain
if(ref $entry eq 'HASH' && scalar keys %{$entry} == 1) {
return _parse_condition((values %{$entry})[0], (keys %{$entry})[0]);
}
# If we get here, we don't know how to handle it
die "Invalid entry $entry";
} #_parse_condition()
=head2 _croak
Lazy L<Carp/croak>
Lazy invoker for L<Carp/croak>.
=cut
sub _croak {
require Carp;
goto &Carp::croak;
}
} #_croak()
=head2 line_mark_string

View file

@ -19,8 +19,6 @@
#
# This file uses core Perl modules only.
use autodie;
use FindBin '$Bin';
use lib $Bin;
@ -32,7 +30,10 @@ use TestHelpers;
# These two lines are all that's required to set up for a test.
my $tenv = TestEnvironment->new;
$tenv->build_repo(sibling_abs_path('tree'))->build_db->update_env;
$tenv->build_repo(sibling_abs_path('tree')); # dies on error
eval { $tenv->build_db; };
warn "Could not update database: $@" if $@;
$tenv->update_env;
print($tenv->report);
@ -44,7 +45,9 @@ system(qw(ln -s), $tenv->update_py, 'update.py') == 0
or warn "error creating ./update.py: $? $!";
system(qw(ln -s), $tenv->query_py, 'query.py') == 0
or warn "error creating ./query.py: $? $!";
system(qw(ln -s), $tenv->find_doc, 'find-file-doc-comments.pl') == 0
or warn "error creating ./find-file-doc-comments.pl: $? $!";
print("Exit when done, and the repository and database will be removed.\n");
system($ENV{SHELL} || 'sh');
my $retval = system($ENV{SHELL} || 'sh');
exit $retval>>8;

View file

@ -18,6 +18,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with Elixir. If not, see <http://www.gnu.org/licenses/>.
# Throughout, an "idx" is the sequential number associated with a blob.
# This is different from that blob's Git hash.
from sys import argv
from lib import scriptLines
import lib
@ -25,6 +28,8 @@ import data
import os
from data import PathList
verbose = False
db = data.DB(lib.getDataDir(), readonly=False)
# Store new blobs hashed and file names (without path) for new tag
@ -39,17 +44,19 @@ def updateBlobIDs(tag):
# Get blob hashes and associated file names (without path)
blobs = scriptLines('list-blobs', '-f', tag)
newBlobs = []
newIdxes = []
for blob in blobs:
hash, filename = blob.split(b' ',maxsplit=1)
if not db.blob.exists(hash):
db.blob.put(hash, idx)
db.hash.put(idx, hash)
db.file.put(idx, filename)
newBlobs.append(idx)
newIdxes.append(idx)
if verbose:
print(f"New blob #{idx} {hash}:{filename}")
idx += 1
db.vars.put('numBlobs', idx)
return newBlobs
return newIdxes
def updateVersions(tag):
@ -66,13 +73,15 @@ def updateVersions(tag):
obj = PathList()
for idx, path in buf:
obj.append(idx, path)
if verbose:
print(f"Tag {tag}: adding #{idx} {path}")
db.vers.put(tag, obj, sync=True)
def updateDefinitions(blobs):
for blob in blobs:
if (blob % 1000 == 0): progress('defs: ' + str(blob))
hash = db.hash.get(blob)
filename = db.file.get(blob)
def updateDefinitions(idxes):
for idx in idxes:
if (idx % 1000 == 0): progress('defs: ' + str(idx))
hash = db.hash.get(idx)
filename = db.file.get(idx)
if not lib.hasSupportedExt(filename): continue
@ -87,14 +96,16 @@ def updateDefinitions(blobs):
else:
obj = data.DefList()
obj.append(blob, type, line)
obj.append(idx, type, line)
if verbose:
print(f"def {type} {ident} in #{idx} @ {line}");
db.defs.put(ident, obj)
def updateReferences(blobs):
for blob in blobs:
if (blob % 1000 == 0): progress('refs: ' + str(blob))
hash = db.hash.get(blob)
filename = db.file.get(blob)
def updateReferences(idxes):
for idx in idxes:
if (idx % 1000 == 0): progress('refs: ' + str(idx))
hash = db.hash.get(idx)
filename = db.file.get(idx)
if not lib.hasSupportedExt(filename): continue
@ -119,9 +130,34 @@ def updateReferences(blobs):
else:
obj = data.RefList()
obj.append(blob, lines)
obj.append(idx, lines)
if verbose:
print(f"ref: {ident} in #{idx} @ {lines}");
db.refs.put(ident, obj)
def updateDocComments(idxes):
for idx in idxes:
if (idx % 1000 == 0): progress('docs: ' + str(idx))
hash = db.hash.get(idx)
filename = db.file.get(idx)
if not lib.hasSupportedExt(filename): continue
lines = scriptLines('parse-docs', hash, filename)
for l in lines:
ident, line = l.split(b' ')
line = int(line.decode())
if db.docs.exists(ident):
obj = db.docs.get(ident)
else:
obj = data.RefList()
obj.append(idx, str(line))
if verbose:
print(f"doc: {ident} in #{idx} @ {line}");
db.docs.put(ident, obj)
def progress(msg):
print('{} - {} ({:.0%})'.format(project, msg, tagCount/numTags))
@ -140,8 +176,9 @@ print(project + ' - found ' + str(len(tagBuf)) + ' new tags')
for tag in tagBuf:
tagCount +=1
newBlobs = updateBlobIDs(tag)
progress(tag.decode() + ': ' + str(len(newBlobs)) + ' new blobs')
newIdxes = updateBlobIDs(tag)
progress(tag.decode() + ': ' + str(len(newIdxes)) + ' new blobs')
updateVersions(tag)
updateDefinitions(newBlobs)
updateReferences(newBlobs)
updateDefinitions(newIdxes)
updateReferences(newIdxes)
updateDocComments(newIdxes)